feat(linkedin-thought-leadership): v1.0.0 — initial open-source import

Build LinkedIn thought leadership with algorithmic understanding,
strategic consistency, and AI-assisted content creation. Updated for
the January 2026 360Brew algorithm change.

16 agents, 25 commands, 6 skills, 9 hooks, 24 reference docs.

Personal data sanitized: voice samples generalized to template,
high-engagement posts cleared, region-specific references replaced
with placeholders.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-04-07 22:09:03 +02:00
commit 39f8b275a6
143 changed files with 32662 additions and 0 deletions

View file

@ -0,0 +1,94 @@
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/session-start.mjs",
"timeout": 10
}
]
}
],
"PreToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/content-gatekeeper.mjs content-quality-gate.md",
"timeout": 5
},
{
"type": "command",
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/content-gatekeeper.mjs voice-guardian.md",
"timeout": 5
},
{
"type": "command",
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/content-gatekeeper.mjs topic-rotation-gate.md",
"timeout": 5
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/stop-reminder.mjs",
"timeout": 10
}
]
}
],
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/user-prompt-context.mjs",
"timeout": 5
}
]
}
],
"PostToolUse": [
{
"matcher": "Write",
"hooks": [
{
"type": "command",
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/content-gatekeeper.mjs post-creation-automation.md --no-session-marker",
"timeout": 5
}
]
}
],
"PreCompact": [
{
"hooks": [
{
"type": "command",
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/pre-compact.mjs",
"timeout": 5
}
]
}
],
"Notification": [
{
"matcher": "idle_prompt",
"hooks": [
{
"type": "command",
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/posting-reminder.mjs",
"timeout": 5
}
]
}
]
}
}

View file

@ -0,0 +1,94 @@
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/session-start.mjs",
"timeout": 10
}
]
}
],
"PreToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/content-gatekeeper.mjs content-quality-gate.md",
"timeout": 5
},
{
"type": "command",
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/content-gatekeeper.mjs voice-guardian.md",
"timeout": 5
},
{
"type": "command",
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/content-gatekeeper.mjs topic-rotation-gate.md",
"timeout": 5
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/stop-reminder.mjs",
"timeout": 10
}
]
}
],
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/user-prompt-context.mjs",
"timeout": 5
}
]
}
],
"PostToolUse": [
{
"matcher": "Write",
"hooks": [
{
"type": "command",
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/content-gatekeeper.mjs post-creation-automation.md --no-session-marker",
"timeout": 5
}
]
}
],
"PreCompact": [
{
"hooks": [
{
"type": "command",
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/pre-compact.mjs",
"timeout": 5
}
]
}
],
"Notification": [
{
"matcher": "idle_prompt",
"hooks": [
{
"type": "command",
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/posting-reminder.mjs",
"timeout": 5
}
]
}
]
}
}

View file

@ -0,0 +1,21 @@
LINKEDIN CONTENT QUALITY GATE: If the file being written/edited is LinkedIn content (a post draft, article, or content file — NOT config files, state files, scripts, or documentation), verify these requirements before proceeding:
**Hook Check:**
- The first line (hook) MUST be 110-140 characters. Count precisely.
- If over 140: the hook gets cut off on mobile. Shorten it.
- If under 110: wasting prime real estate. Expand it.
**Link Check:**
- NO external links (http/https URLs) in the post body. LinkedIn suppresses reach by 40-50% for posts with links.
- If a link is needed, instruct the user to put it in the FIRST COMMENT after posting.
**Tone Check:**
- Scan for corporate buzzwords: 'leverage', 'synergy', 'paradigm shift', 'thought leader', 'disruptive', 'value proposition', 'ecosystem', 'holistic approach', 'actionable insights', 'best practices'.
- If 2+ are found, flag: 'This reads corporate. LinkedIn rewards authentic, conversational tone. Replace buzzwords with plain language.'
**Length Check:**
- Standard posts: 1,200-1,800 characters optimal.
- Quick posts: 150-500 characters.
- If outside range, flag with specific character count.
**Skip this check** if the file is a config file, state file (.local.md), script, hook, JSON, or documentation file. Only apply to LinkedIn content.

View file

@ -0,0 +1,32 @@
LINKEDIN POST-CREATION AUTOMATION: If a LinkedIn content file was just written (post draft, article, or content — NOT config, state, scripts, or docs), perform these post-processing steps:
**1. Generate Alternative Hooks**
Create 3 alternative hooks for the content just written. Present them as:
```
Alternative hooks:
1. [hook 1] (X chars)
2. [hook 2] (X chars)
3. [hook 3] (X chars)
```
**2. Suggest Optimal Posting Time**
Based on the day of the week, suggest the next optimal posting window:
- Tuesday-Thursday: 8-9 AM or 12-1 PM CET (best)
- Monday/Friday: 9-10 AM CET (good)
- Weekend: 10-11 AM CET (lower reach but less competition)
**3. 5x5x5 Engagement Reminder**
Remind: 'Before posting, spend 15-20 minutes on 5x5x5 pre-engagement: find 5 people with overlapping audiences, comment thoughtfully on their recent posts.'
**4. Content Logging**
Note: The post topic and hook should be logged to the state file when the session ends (handled by Stop hook).
**5. Voice Sample Suggestion**
After generating alternative hooks and posting time, add a brief note:
"Tip: Your post hook could become a voice sample. When the session ends, the Stop hook will ask if you'd like to save it to your voice profile."
This creates awareness of the voice extraction feature without interrupting the post-creation flow.
**Skip this** if the file written is a config file, state file (.local.md), script, hook, JSON, plan file, or documentation.

View file

@ -0,0 +1,73 @@
Before ending this LinkedIn content session, do two things:
**1. Update State File**
If a post was created or finalized in this session, update `~/.claude/linkedin-thought-leadership.local.md`:
- Set `last_post_date` to today (YYYY-MM-DD format)
- Set `last_post_topic` to the main topic (use the matching `expertise_areas` value when possible for consistent pillar tracking)
- If `first_post_date` is null and a post was created, set `first_post_date` to today (YYYY-MM-DD). This is set ONCE and never changed after that.
- Check if ISO week has changed — if so, reset `posts_this_week` to 0 and update `current_week`
- Increment `posts_this_week`
- Update streak: increment `current_streak` if posting on consecutive days (gap <= 1 day), reset to 1 if gap > 2 days
- Update `longest_streak` if current exceeds it
- Add entry to '## Recent Posts' section: [YYYY-MM-DD] "Hook text..." (char count) - topic_area (use expertise_area name for consistent pillar tracking)
- Clear `next_planned_topic` if it was used, or set it to the next suggested topic
- If analytics data was imported in this session, set `last_import_date` to today (YYYY-MM-DD) and `last_import_week` to current ISO week (YYYY-WXX)
- If the user mentioned or updated their follower count during this session:
- Update `follower_count` to the new value
- If the month changed since last monthly_growth entry, append: {month: "YYYY-MM", count: X, delta: X}
- Recalculate `growth_rate_needed`: (follower_target - follower_count) / months_remaining
- Recalculate `projected_10k_date` from average of last 3 monthly deltas
- Add entry to '## Milestone Log': [YYYY-MM] count (+delta)
**2. Pre-Publish Reminders** (only if a post was created)
- **Quality Check**: Has content been reviewed against quality scorecard? Hook 110-140 chars, 1,200-1,800 chars total, authentic tone, no external links.
- **5x5x5 Engagement**: Before posting, complete 15-20 min pre-posting engagement — 5 people with overlapping audiences, find their recent posts, write 5 thoughtful comments (15+ words each).
- **First-Hour Plan**: Respond within 5 minutes to first comments. Add value in responses. Target 15+ engagements in first hour.
- **Posting Time**: Post when target audience is most active.
**3. Queue Status Check**
If posts were added to the queue during this session (`assets/drafts/queue.json` was modified):
- Confirm how many posts were queued and their scheduled dates
- Remind: "View your full schedule with /linkedin:calendar"
If a scheduled post was published during this session:
- Verify it was marked as published in queue.json (status = "published")
- If not, remind: "Run /linkedin:publish to update the queue status"
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)
If a LinkedIn post was created or finalized in this session, consider extracting the hook line as a voice sample:
- Read the hook line from the post that was just created
- Check if `assets/voice-samples/authentic-voice-samples.md` exists
- If it does, suggest appending a new entry to the "## Update Log" section at the bottom:
```
- [YYYY-MM-DD]: "[Hook text]" — [post type] (extracted from session post)
```
- **Ask the user for approval before writing.** Say: "Would you like me to save this hook as a voice sample for future reference?"
- Only write if the user approves
- This passively grows the voice profile over time, improving personalization score
**5. Content History Log** (if a post was created)
If a LinkedIn post was created or finalized, append an entry to the content history log:
- If `assets/analytics/content-history.md` does not exist, initialize it from `config/content-history.template.md`
- Append a new row to the "## Content Log" table:
```
| YYYY-MM-DD | "Hook text..." | topic_area | format | word_count | char_count | source |
```
Where:
- `date`: Today's date
- `hook`: First 60 characters of the hook line
- `topic`: Matching expertise_area value (for pillar tracking)
- `format`: post/quick/react/video/pipeline
- `word_count`: Word count of the full post
- `char_count`: Character count of the full post
- `source`: original/url/curated (where the idea came from)
- This is append-only — never edit or delete existing entries
- This log enables `/linkedin:report` and `analytics-interpreter` to track content production over time without requiring LinkedIn CSV imports

View file

@ -0,0 +1,37 @@
LINKEDIN TOPIC ROTATION GATE: If the file being written/edited is LinkedIn content (a post draft, article, or content file — NOT config files, state files, scripts, documentation, JSON, or plan files), check topic diversity before proceeding.
**Step 1: Read State**
Read `~/.claude/linkedin-thought-leadership.local.md` and extract:
- `last_post_topic` — the pillar of the most recent post
- `expertise_areas` — the user's 5 content pillars
- `## Recent Posts` section — post history with topic_area tags
**Step 2: Identify Current Pillar**
Determine which expertise_area the current post best matches. Use semantic matching — the post doesn't need to use the exact pillar name, but its core topic should clearly map to one of the 5 expertise_areas.
**Step 3: Run Checks**
If fewer than 3 posts exist in the last 14 days, skip all checks (insufficient data for meaningful rotation analysis).
**Check 1 — Back-to-back repetition:**
If the current post's pillar matches `last_post_topic`, flag:
> "TOPIC ROTATION WARNING: This post covers the same pillar ([pillar]) as your last post. Consider switching to an underrepresented pillar for better audience diversity and algorithmic reach."
**Check 2 — 14-day balance:**
Count posts per pillar from the `## Recent Posts` section (last 14 days only). If any single pillar accounts for more than 50% of posts in that window, flag:
> "PILLAR BALANCE WARNING: [pillar] has [X] of [Y] posts ([Z]%) in the last 14 days. LinkedIn's algorithm rewards topic consistency across your niche, but over-concentration on one pillar signals narrowing expertise."
**Check 3 — Off-topic:**
If the current post does not match ANY of the 5 expertise_areas, flag:
> "OFF-TOPIC WARNING: This post doesn't align with any of your 5 expertise areas. Off-pillar posts weaken your 360Brew topical authority signal. Consider reframing to connect with [closest pillar]."
**Step 4: Suggest Alternatives**
If any check flagged, suggest 2-3 underrepresented pillars with context:
> "Underrepresented pillars to consider:
> - [Pillar A] — last posted [X] days ago ([N] posts in 14 days)
> - [Pillar B] — last posted [Y] days ago ([M] posts in 14 days)
> - [Pillar C] — last posted [Z] days ago ([P] posts in 14 days)"
**This is a WARN-ONLY hook.** Do not block content creation. Present the warning and let the user decide whether to adjust.
**Skip this check** if the file is a config file, state file (.local.md), script, hook, JSON, plan file, documentation, or any non-content file. Only apply to LinkedIn post drafts and articles.

View file

@ -0,0 +1,26 @@
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:
**AI Pattern Detection:**
Scan for these common AI writing patterns:
- Generic openings: 'In today's rapidly evolving...', 'As we navigate...', 'In the ever-changing landscape...'
- Filler phrases: 'It's worth noting that', 'It goes without saying', 'At the end of the day'
- Overused transitions: 'Furthermore', 'Moreover', 'Additionally', 'In conclusion'
- AI superlatives: 'game-changing', 'revolutionary', 'transformative', 'groundbreaking'
- List padding: Adding obvious points just to fill a list
- Hedging language: 'It could be argued', 'One might say', 'Perhaps'
- Perfect structure: Every paragraph exactly the same length
**Authenticity Score:**
If 3+ AI patterns detected, flag: 'Voice Guardian Alert: This content scores below authenticity threshold. AI patterns found: [list specific patterns]. Suggested fixes: [specific rewrites using natural language].'
**Voice Matching:**
If voice samples exist at `${CLAUDE_PLUGIN_ROOT}/assets/voice-samples/`, compare the writing style against the user's authentic voice patterns. Flag deviations.
**Humanization Tips:**
- Add specific personal anecdotes or observations
- Use conversational contractions (I've, don't, it's)
- Include imperfect/real-world examples
- Vary paragraph and sentence length naturally
- Reference specific people, tools, or experiences
**Skip this check** if the file is config, state (.local.md), script, hook, JSON, or documentation.

View file

@ -0,0 +1,90 @@
#!/usr/bin/env python3
"""Compile hooks.template.json + prompt .md files into hooks.json.
Usage:
python3 hooks/scripts/compile-hooks.py # Generate hooks.json
python3 hooks/scripts/compile-hooks.py --check # Verify hooks.json is up to date
"""
import json
import sys
from pathlib import Path
HOOKS_DIR = Path(__file__).resolve().parent.parent
TEMPLATE = HOOKS_DIR / "hooks.template.json"
OUTPUT = HOOKS_DIR / "hooks.json"
PROMPTS_DIR = HOOKS_DIR / "prompts"
def load_prompt(filename: str) -> str:
"""Load a prompt .md file and return its content as a string."""
path = PROMPTS_DIR / filename
if not path.exists():
print(f"ERROR: Prompt file not found: {path}", file=sys.stderr)
sys.exit(1)
content = path.read_text(encoding="utf-8")
if not content.strip():
print(f"ERROR: Prompt file is empty: {path}", file=sys.stderr)
sys.exit(1)
return content.rstrip("\n")
def resolve_prompts(obj):
"""Recursively walk JSON and replace prompt_file with inline prompt."""
if isinstance(obj, dict):
if "prompt_file" in obj:
if obj.get("type") != "prompt":
print(
f"ERROR: prompt_file used on non-prompt hook type: {obj.get('type')}",
file=sys.stderr,
)
sys.exit(1)
filename = obj.pop("prompt_file")
obj["prompt"] = load_prompt(filename)
return {k: resolve_prompts(v) for k, v in obj.items()}
if isinstance(obj, list):
return [resolve_prompts(item) for item in obj]
return obj
def compile_hooks() -> str:
"""Read template, resolve prompts, return JSON string."""
if not TEMPLATE.exists():
print(f"ERROR: Template not found: {TEMPLATE}", file=sys.stderr)
sys.exit(1)
template = json.loads(TEMPLATE.read_text(encoding="utf-8"))
resolved = resolve_prompts(template)
# Strip any top-level keys except "hooks" — Claude Code requires only "hooks"
invalid_keys = [k for k in resolved if k != "hooks"]
for k in invalid_keys:
print(f"WARNING: Stripping invalid top-level key '{k}' from output", file=sys.stderr)
del resolved[k]
return json.dumps(resolved, indent=2, ensure_ascii=False) + "\n"
def main():
check_mode = "--check" in sys.argv
compiled = compile_hooks()
if check_mode:
if not OUTPUT.exists():
print(f"ERROR: {OUTPUT} does not exist", file=sys.stderr)
sys.exit(1)
current = OUTPUT.read_text(encoding="utf-8")
if current == compiled:
print("OK: hooks.json is up to date")
sys.exit(0)
else:
print(
"DRIFT DETECTED: hooks.json does not match compiled output.\n"
"Run: python3 hooks/scripts/compile-hooks.py",
file=sys.stderr,
)
sys.exit(1)
OUTPUT.write_text(compiled, encoding="utf-8")
print(f"Compiled {OUTPUT.relative_to(HOOKS_DIR.parent)}")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,70 @@
#!/usr/bin/env node
// content-gatekeeper.mjs
// Unified PreToolUse/PostToolUse gatekeeper for linkedin-thought-leadership plugin
//
// Replaces 4 nearly identical bash scripts:
// pre-content-quality-gate.sh, pre-voice-guardian.sh,
// pre-topic-rotation-gate.sh, post-creation-check.sh
//
// Usage:
// node content-gatekeeper.mjs <prompt-filename> [--no-session-marker]
//
// Arguments:
// prompt-filename - Prompt file in hooks/prompts/ (e.g. content-quality-gate.md)
// --no-session-marker - Skip creating session-active marker (for PostToolUse)
//
// Exit codes:
// 0 - Always allow (injects systemMessage or passes through)
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { isLinkedInContent } from './linkedin-content-filter.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const pluginRoot = join(__dirname, '..', '..');
const promptFile = process.argv[2];
const noSessionMarker = process.argv.includes('--no-session-marker');
if (!promptFile) {
process.stdout.write('{}');
process.exit(0);
}
// Read and parse stdin JSON
let input;
try {
input = JSON.parse(readFileSync(0, 'utf-8'));
} catch {
process.stdout.write('{}');
process.exit(0);
}
// Extract file_path from tool_input
const toolInput = input.tool_input ?? {};
const filePath = toolInput.file_path ?? toolInput.filePath ?? '';
// Check if this is LinkedIn content
if (!isLinkedInContent(filePath)) {
process.stdout.write('{}');
process.exit(0);
}
// Mark session as having LinkedIn content activity
if (!noSessionMarker) {
const sessionDir = '/tmp/linkedin-hooks';
mkdirSync(sessionDir, { recursive: true });
writeFileSync(join(sessionDir, 'session-active'), '');
}
// Load and return prompt
const promptPath = join(pluginRoot, 'hooks', 'prompts', promptFile);
if (!existsSync(promptPath)) {
process.stdout.write('{}');
process.exit(0);
}
const promptContent = readFileSync(promptPath, 'utf-8');
process.stdout.write(JSON.stringify({ systemMessage: promptContent }));
process.exit(0);

View file

@ -0,0 +1,40 @@
#!/usr/bin/env node
// Shared module: determines if a file path is LinkedIn content
// Import: import { isLinkedInContent } from './linkedin-content-filter.mjs';
// Returns true for content, false for non-content
import { basename, extname } from 'node:path';
export function isLinkedInContent(filePath) {
if (!filePath) return false;
const base = basename(filePath);
const ext = extname(base).slice(1); // remove leading dot
// NEGATIVE: code/config extensions
if (['sh', 'py', 'js', 'mjs', 'ts', 'jsx', 'tsx', 'json', 'yaml', 'yml', 'toml', 'css', 'html'].includes(ext)) {
return false;
}
// NEGATIVE: template files
if (base.includes('.template')) return false;
// NEGATIVE: known non-content filenames
const nonContent = ['.local.md', 'CLAUDE.md', 'README.md', 'CHANGELOG.md', 'REMEMBER.md', 'BACKLOG.md', 'DEVELOPMENT-LOG.md'];
if (nonContent.some(n => base.endsWith(n) || base === n)) return false;
// NEGATIVE: infrastructure paths
const infraDirs = ['hooks', 'scripts', 'config', 'commands', 'agents', 'skills', 'references', 'docs', '.claude', '.claude-plugin', 'node_modules'];
const normalized = filePath.replace(/\\/g, '/');
for (const dir of infraDirs) {
if (normalized.startsWith(dir + '/') || normalized.includes('/' + dir + '/')) return false;
}
// POSITIVE: explicit LinkedIn content paths only
if (normalized.startsWith('assets/drafts/') || normalized.includes('/assets/drafts/')) return true;
if (normalized.includes('/linkedin-posts/')) return true;
if (normalized.includes('/linkedin-thought-leadership/assets/')) return true;
// DEFAULT: everything else is NOT LinkedIn content
return false;
}

View file

@ -0,0 +1,120 @@
#!/usr/bin/env node
// Personalization score calculator for linkedin-thought-leadership plugin
// Checks 8 asset categories for real user data vs placeholder templates
// Standalone: outputs SCORE:N|M/8 assets personalized
// Import: export function calculateScore(pluginRoot) => { score, personalized, categories }
import { readFileSync, existsSync, readdirSync } from 'node:fs';
import { join, basename, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
export function calculateScore(pluginRoot) {
let score = 0;
let personalized = 0;
const categories = 8;
// --- 1. Voice samples (25 points) ---
const voiceFile = join(pluginRoot, 'assets', 'voice-samples', 'authentic-voice-samples.md');
if (existsSync(voiceFile)) {
const content = readFileSync(voiceFile, 'utf-8');
const lineCount = content.split('\n').length;
if (lineCount > 50 && !content.includes('[Your Name]')) {
score += 25;
personalized += 1;
}
}
// --- 2. User profile (20 points) ---
const profileFile = join(pluginRoot, 'config', 'user-profile.local.md');
if (existsSync(profileFile)) {
const content = readFileSync(profileFile, 'utf-8');
const placeholderCount = (content.match(/\[Your /g) || []).length;
if (placeholderCount < 3) {
score += 20;
personalized += 1;
}
}
// --- 3. Case studies (15 points) ---
const caseDir = join(pluginRoot, 'assets', 'case-studies');
if (existsSync(caseDir)) {
let realCases = 0;
try {
for (const f of readdirSync(caseDir)) {
if (!f.endsWith('.md')) continue;
if (f === 'case-study-template.md') continue;
realCases++;
}
} catch { /* ignore */ }
if (realCases >= 2) { score += 15; personalized += 1; }
else if (realCases >= 1) { score += 8; }
}
// --- 4. Frameworks (10 points) ---
const fwDir = join(pluginRoot, 'assets', 'frameworks');
if (existsSync(fwDir)) {
let realFw = 0;
try {
for (const f of readdirSync(fwDir)) {
if (!f.endsWith('.md')) continue;
if (f === 'framework-template.md') continue;
realFw++;
}
} catch { /* ignore */ }
if (realFw >= 2) { score += 10; personalized += 1; }
else if (realFw >= 1) { score += 5; }
}
// --- 5. High-engagement posts (10 points) ---
const postsFile = join(pluginRoot, 'assets', 'examples', 'high-engagement-posts.md');
if (existsSync(postsFile)) {
const content = readFileSync(postsFile, 'utf-8');
const postCount = (content.match(/^## Post [0-9]/gm) || []).length;
if (postCount >= 3) { score += 10; personalized += 1; }
else if (postCount >= 1) { score += 4; }
}
// --- 6. Demographics (8 points) ---
const demoFile = join(pluginRoot, 'assets', 'audience-insights', 'demographics.md');
if (existsSync(demoFile)) {
const content = readFileSync(demoFile, 'utf-8');
const placeholderCount = (content.match(/\[Industry name\]|\[Function\]|\[Country\]|\[X\]%/g) || []).length;
if (placeholderCount < 5) {
score += 8;
personalized += 1;
}
}
// --- 7. Engagement patterns (7 points) ---
const patternsFile = join(pluginRoot, 'assets', 'audience-insights', 'engagement-patterns.md');
if (existsSync(patternsFile)) {
const content = readFileSync(patternsFile, 'utf-8');
const placeholderCount = (content.match(/\[Day\]|\[Time\]|\[Topic\]|\[Format\]|\[Hook type\]/g) || []).length;
if (placeholderCount < 5) {
score += 7;
personalized += 1;
}
}
// --- 8. Post templates (5 points) ---
const templatesFile = join(pluginRoot, 'assets', 'templates', 'my-post-templates.md');
if (existsSync(templatesFile)) {
const content = readFileSync(templatesFile, 'utf-8');
const unfilled = (content.match(/\[Name - e\.g\./g) || []).length;
const totalTemplates = (content.match(/^## Template [0-9]/gm) || []).length;
const filled = totalTemplates - unfilled;
if (filled >= 2) { score += 5; personalized += 1; }
else if (filled >= 1) { score += 2; }
}
return { score, personalized, categories };
}
// Standalone execution (guarded to prevent stdout contamination on import)
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const pluginRoot = join(__dirname, '..', '..');
const { score, personalized, categories } = calculateScore(pluginRoot);
process.stdout.write(`SCORE:${score}|${personalized}/${categories} assets personalized\n`);
}

View file

@ -0,0 +1,112 @@
#!/usr/bin/env node
// Notification hook for linkedin-thought-leadership plugin
// Fires on idle_prompt to show posting reminders. Rate-limited: max once per 30 min.
import { readFileSync, existsSync, statSync, writeFileSync, mkdirSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { queueToday, queueOverdue } from './queue-manager.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const PLUGIN_ROOT = join(__dirname, '..', '..');
const HOME = process.env.HOME || process.env.USERPROFILE || '';
const STATE_FILE = join(HOME, '.claude', 'linkedin-thought-leadership.local.md');
const SESSION_DIR = '/tmp/linkedin-hooks';
const COOLDOWN_FILE = join(SESSION_DIR, 'last-notification');
const COOLDOWN_SECONDS = 1800;
function extractYaml(content, key) {
const re = new RegExp(`^${key}: *"?([^"\\n]*)"?`, 'm');
const m = content.match(re);
return m ? m[1].trim() : '';
}
function daysSince(dateStr) {
if (!dateStr || dateStr === 'null') return null;
const epoch = new Date(dateStr).getTime();
if (isNaN(epoch)) return null;
return Math.floor((Date.now() - epoch) / 86400000);
}
// Read stdin
let input;
try {
input = JSON.parse(readFileSync(0, 'utf-8'));
} catch {
process.exit(0);
}
if ((input.notification_type || '') !== 'idle_prompt') process.exit(0);
// Rate limiting
if (existsSync(COOLDOWN_FILE)) {
const age = (Date.now() - statSync(COOLDOWN_FILE).mtime.getTime()) / 1000;
if (age < COOLDOWN_SECONDS) process.exit(0);
}
if (!existsSync(STATE_FILE)) process.exit(0);
const stateContent = readFileSync(STATE_FILE, 'utf-8');
const lastPostDate = extractYaml(stateContent, 'last_post_date');
const postsThisWeek = parseInt(extractYaml(stateContent, 'posts_this_week') || '0', 10);
const weeklyGoal = parseInt(extractYaml(stateContent, 'weekly_goal') || '3', 10);
const currentStreak = parseInt(extractYaml(stateContent, 'current_streak') || '0', 10);
const lastImportDate = extractYaml(stateContent, 'last_import_date');
const followerCount = parseInt(extractYaml(stateContent, 'follower_count') || '0', 10);
const followerTarget = parseInt(extractYaml(stateContent, 'follower_target') || '10000', 10);
const reminders = [];
// Days since last post
const dsp = daysSince(lastPostDate);
if (dsp !== null) {
if (dsp >= 3) reminders.push(`No LinkedIn post in ${dsp} days. Posting gaps >5 days reduce reach by 15-25%. Consider running /linkedin:quick or /linkedin:pipeline.`);
if (dsp >= 2 && currentStreak > 3) reminders.push(`Your ${currentStreak}-day posting streak is at risk! Last post was ${dsp} days ago. Post today to keep momentum.`);
}
// Weekly goal
const remaining = weeklyGoal - postsThisWeek;
const dow = new Date().getDay() || 7; // 1=Mon, 7=Sun
if (remaining > 0) {
if (dow >= 4 && remaining >= 2) reminders.push(`${remaining} posts remaining to hit your weekly goal of ${weeklyGoal}. It's already late in the week — consider /linkedin:batch to catch up.`);
if (dow >= 5 && remaining >= 1) reminders.push(`Weekly goal: ${postsThisWeek}/${weeklyGoal} posts. ${remaining} to go before the week ends.`);
}
// Import staleness
const dsi = daysSince(lastImportDate);
if (dsi !== null) {
if (dsi >= 14) reminders.push(`Analytics data is ${dsi} days stale. Run /linkedin:import to update your performance data.`);
else if (dsi >= 7) reminders.push(`Have you imported this week's LinkedIn data? Last import was ${dsi} days ago. Run /linkedin:import.`);
} else {
reminders.push('No LinkedIn analytics imported yet. Run /linkedin:import to start tracking performance.');
}
// Milestone
if (followerCount > 0 && followerTarget > 0) {
const pct = Math.floor(followerCount * 100 / followerTarget);
reminders.push(`10K milestone: ${followerCount}/${followerTarget} followers (${pct}% complete).`);
}
// Queue reminders
try {
const todayEntries = queueToday();
const overdueEntries = queueOverdue();
if (todayEntries.length > 0) reminders.push(`You have ${todayEntries.length} post(s) scheduled for today. Run /linkedin:publish after posting to update your tracking.`);
if (overdueEntries.length > 0) reminders.push(`${overdueEntries.length} overdue post(s) in your queue. Run /linkedin:publish to mark as posted, or /linkedin:calendar to reschedule.`);
} catch { /* ignore */ }
// Peak posting time
const hour = new Date().getHours();
if (dow >= 2 && dow <= 4) {
if (hour >= 7 && hour <= 8) reminders.push('Peak posting window approaching: 8-9 AM CET on Tue-Thu is optimal for LinkedIn engagement.');
if (hour >= 11 && hour <= 12) reminders.push('Secondary peak posting window: 12-1 PM CET on Tue-Thu is good for LinkedIn engagement.');
}
if (reminders.length > 0) {
mkdirSync(SESSION_DIR, { recursive: true });
writeFileSync(COOLDOWN_FILE, '');
const output = 'LinkedIn Posting Reminders:\n' + reminders.map(r => `- ${r}`).join('\n');
process.stdout.write(JSON.stringify({ systemMessage: output }));
} else {
process.stdout.write('{}');
}

View file

@ -0,0 +1,29 @@
#!/usr/bin/env node
// pre-compact.mjs
// PreCompact hook for linkedin-thought-leadership plugin
// Reminds Claude to preserve critical LinkedIn session context before compaction
//
// Exit codes:
// 0 - Always allow (informational hook)
const context = [
'Before compacting context, preserve these critical LinkedIn session details:',
'- Current post draft (full text if in progress)',
'- Chosen angle and format',
'- User feedback and iteration direction',
'- Quality check results',
'- State file values (streak, weekly count, last post date)',
'- Any planned topics or next steps',
'Ensure these survive the context compaction.',
].join('\n');
const output = {
continue: true,
hookSpecificOutput: {
hookEventName: 'PreCompact',
additionalContext: context,
},
};
process.stdout.write(JSON.stringify(output));
process.exit(0);

View file

@ -0,0 +1,125 @@
#!/usr/bin/env node
// Queue management library for linkedin-thought-leadership plugin
// Import: import { queueRead, queueToday, ... } from './queue-manager.mjs';
// Replaces python3 dependency with native Node.js JSON/Date operations
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const PLUGIN_ROOT = process.env.PLUGIN_ROOT || join(__dirname, '..', '..');
const QUEUE_FILE = join(PLUGIN_ROOT, 'assets', 'drafts', 'queue.json');
function ensureQueue() {
if (!existsSync(QUEUE_FILE)) {
mkdirSync(dirname(QUEUE_FILE), { recursive: true });
writeFileSync(QUEUE_FILE, JSON.stringify({ version: 1, queue: [] }, null, 2));
}
}
function readQueue() {
ensureQueue();
try {
const data = JSON.parse(readFileSync(QUEUE_FILE, 'utf-8'));
return data.queue || [];
} catch {
return [];
}
}
function writeQueue(queue) {
ensureQueue();
const data = JSON.parse(readFileSync(QUEUE_FILE, 'utf-8'));
data.queue = queue;
writeFileSync(QUEUE_FILE, JSON.stringify(data, null, 2));
}
function todayISO() {
return new Date().toISOString().slice(0, 10);
}
// Read all queue entries
export function queueRead() {
return readQueue();
}
// Get entries scheduled for today (status=scheduled only)
export function queueToday() {
const today = todayISO();
return readQueue().filter(e => e.scheduled_date === today && e.status === 'scheduled');
}
// Get entries for next N days (status=scheduled only)
export function queueUpcoming(days = 7) {
const today = todayISO();
const end = new Date();
end.setDate(end.getDate() + days);
const endStr = end.toISOString().slice(0, 10);
return readQueue()
.filter(e => e.status === 'scheduled' && e.scheduled_date >= today && e.scheduled_date <= endStr)
.sort((a, b) => (a.scheduled_date + (a.scheduled_time || '')).localeCompare(b.scheduled_date + (b.scheduled_time || '')));
}
// Add entry to queue
export function queueAdd(id, draftPath, schedDate, schedTime, pillar, format, hookPreview, charCount) {
const queue = readQueue().filter(e => e.id !== id);
queue.push({
id,
draft_path: draftPath,
scheduled_date: schedDate,
scheduled_time: schedTime,
pillar,
format,
hook_preview: hookPreview,
character_count: charCount,
status: 'scheduled',
created_at: todayISO()
});
writeQueue(queue);
return `Added: ${id}`;
}
// Update status of a queue entry
export function queueUpdateStatus(id, newStatus) {
const queue = readQueue();
const entry = queue.find(e => e.id === id);
if (entry) {
entry.status = newStatus;
writeQueue(queue);
return `Updated: ${id} -> ${newStatus}`;
}
return `Not found: ${id}`;
}
// Get overdue entries (past scheduled_date, still "scheduled")
export function queueOverdue() {
const today = todayISO();
return readQueue()
.filter(e => e.status === 'scheduled' && (e.scheduled_date || '9999') < today)
.sort((a, b) => (a.scheduled_date || '').localeCompare(b.scheduled_date || ''));
}
// Count entries by status
export function queueCount() {
const counts = {};
for (const e of readQueue()) {
const s = e.status || 'unknown';
counts[s] = (counts[s] || 0) + 1;
}
return counts;
}
// Format queue entries as readable summary
export function queueFormatSummary(entries) {
if (!entries || entries.length === 0) return '(none)';
return entries.map(e => {
const d = e.scheduled_date || '?';
const t = e.scheduled_time || '?';
const hook = (e.hook_preview || '').slice(0, 50);
const pillar = e.pillar || '?';
const fmt = e.format || '?';
const status = e.status || '?';
return ` ${d} ${t} | ${hook}... | ${pillar} (${fmt}) [${status}]`;
}).join('\n');
}

View file

@ -0,0 +1,86 @@
#!/usr/bin/env node
// Quick-import helper for linkedin-thought-leadership plugin
// Opens LinkedIn analytics in browser, watches ~/Downloads for new CSV files
import { existsSync, mkdirSync, readdirSync, statSync, copyFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { exec } from 'node:child_process';
const __dirname = dirname(fileURLToPath(import.meta.url));
const PLUGIN_ROOT = join(__dirname, '..', '..');
const HOME = process.env.HOME || process.env.USERPROFILE || '';
const EXPORTS_DIR = join(PLUGIN_ROOT, 'assets', 'analytics', 'exports');
const DOWNLOADS_DIR = join(HOME, 'Downloads');
const POLL_INTERVAL = 3000;
const MAX_WAIT = 300000; // 5 minutes
mkdirSync(EXPORTS_DIR, { recursive: true });
// Snapshot existing CSV files
function getCsvFiles() {
try {
return readdirSync(DOWNLOADS_DIR)
.filter(f => f.endsWith('.csv'))
.sort();
} catch { return []; }
}
// Cross-platform browser open
function openUrl(url) {
const cmd = process.platform === 'darwin' ? 'open'
: process.platform === 'win32' ? 'start ""'
: 'xdg-open';
exec(`${cmd} "${url}"`, () => {});
}
const beforeFiles = new Set(getCsvFiles());
console.log('Opening LinkedIn Analytics in your browser...');
openUrl('https://www.linkedin.com/analytics/creator/content/');
console.log('\nInstructions:');
console.log(' 1. Click \'Export\' (top right) in LinkedIn Analytics');
console.log(' 2. LinkedIn will download a CSV to ~/Downloads');
console.log(' 3. This script will detect it automatically\n');
console.log('Watching ~/Downloads for new CSV files (max 5 minutes)...\n');
let elapsed = 0;
const timer = setInterval(() => {
elapsed += POLL_INTERVAL;
const currentFiles = getCsvFiles();
const newFiles = currentFiles.filter(f => !beforeFiles.has(f));
for (const filename of newFiles) {
const filePath = join(DOWNLOADS_DIR, filename);
try {
const age = (Date.now() - statSync(filePath).mtime.getTime()) / 1000;
if (/linkedin|analytics|content|export/i.test(filename) || age < 60) {
console.log(`Detected: ${filename}`);
copyFileSync(filePath, join(EXPORTS_DIR, filename));
console.log(`Copied to: ${EXPORTS_DIR}/${filename}\n`);
console.log('File is ready for import. Run:');
console.log(' /linkedin:import\n');
console.log('Or import directly with:');
console.log(` ANALYTICS_ROOT="${PLUGIN_ROOT}/assets/analytics" node --import tsx "${PLUGIN_ROOT}/scripts/analytics/src/cli.ts" import "${filename}"`);
clearInterval(timer);
process.exit(0);
}
} catch { /* ignore */ }
}
if (elapsed % 15000 === 0) {
const remaining = Math.floor((MAX_WAIT - elapsed) / 60000);
console.log(` Still waiting... (${remaining}m remaining)`);
}
if (elapsed >= MAX_WAIT) {
console.log('\nTimed out after 5 minutes. No new CSV detected.\n');
console.log('You can manually copy the file:');
console.log(` mv ~/Downloads/<linkedin-csv-file>.csv ${EXPORTS_DIR}/`);
console.log(' /linkedin:import');
clearInterval(timer);
process.exit(1);
}
}, POLL_INTERVAL);

View file

@ -0,0 +1,404 @@
#!/usr/bin/env node
// SessionStart hook for linkedin-thought-leadership plugin
// Reads persistent state and session context, outputs JSON with additionalContext
import { readFileSync, existsSync, copyFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { calculateScore } from './personalization-score.mjs';
import { queueToday, queueOverdue, queueUpcoming } from './queue-manager.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const PLUGIN_ROOT = join(__dirname, '..', '..');
const HOME = process.env.HOME || process.env.USERPROFILE || '';
const STATE_FILE = join(HOME, '.claude', 'linkedin-thought-leadership.local.md');
function extractYaml(content, key) {
const re = new RegExp(`^${key}: *"?([^"\\n]*)"?`, 'm');
const m = content.match(re);
return m ? m[1].trim() : '';
}
function daysSince(dateStr) {
if (!dateStr || dateStr === 'null') return null;
const epoch = new Date(dateStr).getTime();
if (isNaN(epoch)) return null;
return Math.floor((Date.now() - epoch) / 86400000);
}
function isoWeek() {
const d = new Date();
const dayNum = d.getUTCDay() || 7;
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
const weekNo = Math.ceil(((d - yearStart) / 86400000 + 1) / 7);
return `${d.getUTCFullYear()}-W${String(weekNo).padStart(2, '0')}`;
}
function dayOfWeek() {
const d = new Date().getDay();
return d === 0 ? 7 : d; // 1=Mon, 7=Sun (ISO)
}
let context = '';
if (existsSync(STATE_FILE)) {
const stateContent = readFileSync(STATE_FILE, 'utf-8');
// Extract YAML frontmatter values
const lastPostDate = extractYaml(stateContent, 'last_post_date');
const lastPostTopic = extractYaml(stateContent, 'last_post_topic');
const postsThisWeek = parseInt(extractYaml(stateContent, 'posts_this_week') || '0', 10);
const weeklyGoal = parseInt(extractYaml(stateContent, 'weekly_goal') || '3', 10);
const currentStreak = parseInt(extractYaml(stateContent, 'current_streak') || '0', 10);
const currentWeek = extractYaml(stateContent, 'current_week');
const nextPlannedTopic = extractYaml(stateContent, 'next_planned_topic');
const lastImportDate = extractYaml(stateContent, 'last_import_date');
const firstPostDate = extractYaml(stateContent, 'first_post_date');
const followerCount = parseInt(extractYaml(stateContent, 'follower_count') || '0', 10);
const followerTarget = parseInt(extractYaml(stateContent, 'follower_target') || '10000', 10);
const targetDate = extractYaml(stateContent, 'target_date');
const growthRateNeeded = parseInt(extractYaml(stateContent, 'growth_rate_needed') || '0', 10);
const projected10kDate = extractYaml(stateContent, 'projected_10k_date');
// Calculate days since last post
const daysSincePost = daysSince(lastPostDate);
const daysSinceImport = daysSince(lastImportDate);
const daysSinceFirstPost = daysSince(firstPostDate);
// New creator boost window
let boostWindowStatus = '';
let boostDaysRemaining = 0;
if (daysSinceFirstPost !== null) {
if (daysSinceFirstPost <= 90) {
boostWindowStatus = 'ACTIVE';
boostDaysRemaining = 90 - daysSinceFirstPost;
} else if (daysSinceFirstPost <= 120) {
boostWindowStatus = 'TRANSITION';
} else {
boostWindowStatus = 'ESTABLISHED';
}
}
// Milestone metrics
let milestonePhase = '';
let milestoneStatus = '';
let followersNeeded = 0;
let monthsRemaining = 0;
let ratePerMonth = 0;
let phaseTransitionAlert = '';
if (followerCount > 0) {
if (followerCount < 1000) milestonePhase = 'Foundation';
else if (followerCount < 3000) milestonePhase = 'Validation';
else if (followerCount < 6000) milestonePhase = 'Acceleration';
else if (followerCount < 10000) milestonePhase = 'Authority';
else milestonePhase = 'Scale';
// Phase transition proximity
const thresholds = [
{ limit: 1000, label: 'Validation phase (1,000)' },
{ limit: 3000, label: 'Acceleration phase (3,000)' },
{ limit: 6000, label: 'Authority phase (6,000)' },
{ limit: 10000, label: 'Scale phase (10,000)' }
];
for (const { limit, label } of thresholds) {
if (followerCount < limit && followerCount >= limit * 0.9) {
phaseTransitionAlert = `${limit - followerCount} followers to ${label}`;
break;
}
}
followersNeeded = Math.max(0, followerTarget - followerCount);
// Calculate months remaining to target_date
if (targetDate && targetDate !== 'null' && targetDate !== '""') {
const [tYear, tMonth] = targetDate.split('-').map(Number);
const now = new Date();
monthsRemaining = (tYear - now.getFullYear()) * 12 + (tMonth - (now.getMonth() + 1));
if (monthsRemaining < 1) monthsRemaining = 1;
ratePerMonth = Math.floor(followersNeeded / monthsRemaining);
}
// Schedule status
if (followerCount >= followerTarget) {
milestoneStatus = 'ACHIEVED';
} else if (growthRateNeeded > 0 && monthsRemaining > 0) {
if (ratePerMonth > growthRateNeeded * 2) milestoneStatus = 'SIGNIFICANTLY BEHIND';
else if (ratePerMonth > growthRateNeeded * 1.2) milestoneStatus = 'BEHIND';
else if (ratePerMonth < growthRateNeeded * 0.8) milestoneStatus = 'AHEAD';
else milestoneStatus = 'ON TRACK';
} else if (followerCount >= followerTarget) {
milestoneStatus = 'ACHIEVED';
} else {
milestoneStatus = 'TRACKING';
}
}
// Week rollover check
const actualWeek = isoWeek();
let weekResetNote = '';
if (currentWeek && currentWeek !== actualWeek) {
weekResetNote = `Note: Week has changed from ${currentWeek} to ${actualWeek}. posts_this_week should be reset to 0.`;
}
// Build status line
let statusLine = `LinkedIn: ${postsThisWeek}/${weeklyGoal} posts this week | Streak: ${currentStreak} days`;
if (lastPostDate && lastPostDate !== 'null') {
statusLine += ` | Last: ${lastPostDate}`;
if (daysSincePost !== null) statusLine += ` (${daysSincePost} days ago)`;
}
if (lastImportDate && lastImportDate !== 'null' && daysSinceImport !== null) {
statusLine += ` | Import: ${daysSinceImport}d ago`;
} else {
statusLine += ' | Import: never';
}
if (milestonePhase && followerCount > 0) {
statusLine += ` | ${followerCount}/${followerTarget} followers (${milestonePhase})`;
}
// Personalization score
let pScore = null;
try {
const { score } = calculateScore(PLUGIN_ROOT);
pScore = score;
statusLine += ` | Personalization: ${score}%`;
} catch { /* ignore */ }
// New creator window
if (boostWindowStatus === 'ACTIVE') {
statusLine += ` | NEW CREATOR: ${boostDaysRemaining}d left`;
}
// Load queue data
let queueTodayEntries = [];
let queueOverdueEntries = [];
let queueUpcomingCount = 0;
try {
queueTodayEntries = queueToday();
queueOverdueEntries = queueOverdue();
queueUpcomingCount = queueUpcoming(7).length;
} catch { /* ignore */ }
const queueTodayCount = queueTodayEntries.length;
const queueOverdueCount = queueOverdueEntries.length;
let queueTodayText = '';
if (queueTodayCount > 0) {
queueTodayText = queueTodayEntries.map(e => {
const t = e.scheduled_time || '?';
const hook = (e.hook_preview || '').slice(0, 50);
const pillar = e.pillar || '?';
const fmt = e.format || '?';
return ` ${t}: "${hook}..." — ${pillar} (${fmt})`;
}).join('\n');
}
let queueOverdueText = '';
if (queueOverdueCount > 0) {
queueOverdueText = queueOverdueEntries.map(e => {
const d = e.scheduled_date || '?';
const hook = (e.hook_preview || '').slice(0, 50);
const pillar = e.pillar || '?';
return ` ${d}: "${hook}..." — ${pillar}`;
}).join('\n');
}
// Build context output
context = 'LinkedIn Thought Leadership session context loaded.\\n\\n';
context += `## Status\\n\`\`\`\\n${statusLine}\\n\`\`\`\\n\\n`;
if (weekResetNote) context += `**${weekResetNote}**\\n\\n`;
if (nextPlannedTopic) context += `**Planned next topic:** ${nextPlannedTopic}\\n\\n`;
if (lastPostTopic) context += `**Last post topic:** ${lastPostTopic}\\n\\n`;
// Recent posts section
const recentMatch = stateContent.match(/^## Recent Posts\n([\s\S]*?)(?=\n## [^R]|\n## $|$)/m);
if (recentMatch) {
const recentPosts = recentMatch[1].split('\n').slice(0, 10).join('\n');
if (recentPosts.trim()) context += `## Recent Posts\\n${recentPosts.replace(/\n/g, '\\n')}\\n\\n`;
}
// Today's scheduled posts
if (queueTodayText) {
context += `## Today's Scheduled Posts\\n${queueTodayText.replace(/\n/g, '\\n')}\\nRun /linkedin:publish after posting to update tracking.\\n\\n`;
}
// Overdue posts
if (queueOverdueText) {
context += `## OVERDUE Posts\\n${queueOverdueText.replace(/\n/g, '\\n')}\\nRun /linkedin:publish to mark as posted, or /linkedin:calendar to reschedule.\\n\\n`;
}
// Posting reminders
let reminders = '';
if (daysSincePost !== null) {
if (daysSincePost >= 3) {
reminders += `- No LinkedIn post in ${daysSincePost} days. Posting gaps >5 days reduce reach by 15-25%. Consider /linkedin:quick or /linkedin:pipeline.\\n`;
}
if (daysSincePost >= 2 && currentStreak > 3) {
reminders += `- Your ${currentStreak}-day posting streak is at risk! Post today to keep momentum.\\n`;
}
}
// First-post nudge
if ((!firstPostDate || firstPostDate === 'null') && postsThisWeek === 0) {
reminders += '- First post not yet created! Run /linkedin:first-post to publish your first LinkedIn post in under 10 minutes.\\n';
}
// Weekly goal check
const weekRemaining = weeklyGoal - postsThisWeek;
const dow = dayOfWeek();
if (weekRemaining > 0 && dow >= 4) {
reminders += `- ${weekRemaining} posts remaining to hit weekly goal of ${weeklyGoal}. It's late in the week.\\n`;
}
// Personalization score check
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`;
}
// Import staleness
if (daysSinceImport !== null) {
if (daysSinceImport >= 14) {
reminders += `- Analytics data is ${daysSinceImport} days stale. Strategy recommendations may be inaccurate. Run /linkedin:import.\\n`;
} else if (daysSinceImport >= 7) {
reminders += `- Last analytics import was ${daysSinceImport} days ago. Consider /linkedin:import for fresh data.\\n`;
}
} else if (!lastImportDate || lastImportDate === 'null') {
reminders += '- No analytics data imported yet. Run /linkedin:import to start tracking performance.\\n';
}
// Milestone reminders
if (milestonePhase && followerCount > 0) {
if (milestoneStatus === 'SIGNIFICANTLY BEHIND') {
reminders += `- 10K milestone: SIGNIFICANTLY BEHIND schedule. Need ~${ratePerMonth} followers/month (2x+ original rate). Run /linkedin:strategy for corrective adjustments — current approach needs a fundamental shift.\\n`;
} else if (milestoneStatus === 'BEHIND') {
reminders += `- 10K milestone: BEHIND schedule. Need ~${ratePerMonth} followers/month. Consider /linkedin:strategy for trajectory-based adjustments.\\n`;
} else if (milestoneStatus === 'AHEAD') {
reminders += '- 10K milestone: AHEAD of schedule. Consider raising target or shifting focus to monetization (/linkedin:monetize).\\n';
}
} else if (!followerCount || followerCount === 0) {
reminders += '- No follower count tracked yet. Update follower_count in state file to enable 10K milestone tracking.\\n';
}
// Phase transition proximity
if (phaseTransitionAlert) {
reminders += `- PHASE TRANSITION: ${phaseTransitionAlert}. Run /linkedin:strategy to prepare.\\n`;
}
// New creator advantage window
if (boostWindowStatus === 'ACTIVE') {
if (boostDaysRemaining < 14) {
reminders += `- NEW CREATOR WINDOW CLOSING: Only ${boostDaysRemaining} days left! Maximize posting frequency (4-5x/week) and engagement (15-20 comments/day) now.\\n`;
} else if (boostDaysRemaining < 30) {
reminders += `- New creator window: ${boostDaysRemaining} days remaining. Maintain high frequency (4-5x/week) to lock in algorithmic momentum.\\n`;
} else {
reminders += `- New creator advantage active (${boostDaysRemaining}d left). Higher posting frequency pays outsized returns during this window.\\n`;
}
} else if (boostWindowStatus === 'TRANSITION') {
reminders += `- New creator window ended ${daysSinceFirstPost} days ago. Transition to sustainable posting rhythm (3-4x/week) and optimize based on analytics.\\n`;
}
// Queue-related reminders
if (queueTodayCount > 0) {
reminders += `- You have ${queueTodayCount} post(s) scheduled for today. Run /linkedin:publish after posting.\\n`;
}
if (queueOverdueCount > 0) {
reminders += `- ${queueOverdueCount} overdue post(s) in queue. Run /linkedin:publish or /linkedin:calendar to manage.\\n`;
}
if (reminders) context += `## Posting Reminders\\n${reminders}\\n`;
// 10K Milestone Tracker section
if (milestonePhase && followerCount > 0) {
context += '## 10K Milestone Tracker\\n';
context += `- Current: ${followerCount} followers (Phase: ${milestonePhase})\\n`;
if (monthsRemaining > 0 && followersNeeded > 0) {
context += `- Required rate: ~${ratePerMonth} followers/month to hit ${followerTarget} by ${targetDate}\\n`;
}
if (milestoneStatus) context += `- Status: ${milestoneStatus}\\n`;
if (projected10kDate && projected10kDate !== 'null' && projected10kDate !== '""') {
context += `- Projected: ${projected10kDate} (at current rate)\\n`;
}
if (phaseTransitionAlert) context += `- PHASE TRANSITION: ${phaseTransitionAlert}\\n`;
if (milestoneStatus === 'SIGNIFICANTLY BEHIND') {
context += '- Trajectory hint: Current approach needs fundamental adjustment. Run /linkedin:strategy for corrective plan.\\n';
} else if (milestoneStatus === 'BEHIND') {
context += '- Trajectory hint: Consider /linkedin:strategy for trajectory-based adjustments to close the gap.\\n';
} else if (milestoneStatus === 'AHEAD') {
context += '- Trajectory hint: Strong momentum. Consider raising target or shifting to monetization (/linkedin:monetize).\\n';
}
context += '\\n';
}
// New creator advantage window context
if (boostWindowStatus === 'ACTIVE') {
context += '## New Creator Advantage Window\\n';
context += `- Status: ACTIVE (day ${daysSinceFirstPost} of 90, ${boostDaysRemaining} days remaining)\\n`;
context += `- First post: ${firstPostDate}\\n`;
context += '- Recommended frequency: 4-5x/week (vs standard 3x)\\n';
context += '- Recommended engagement: 15-20 strategic comments/day\\n';
context += '- Priority: Save-worthy content (frameworks, checklists, templates)\\n\\n';
} else if (boostWindowStatus === 'TRANSITION') {
context += '## New Creator Advantage Window\\n';
context += `- Status: TRANSITION (day ${daysSinceFirstPost}, window closed at day 90)\\n`;
context += '- Shift to sustainable rhythm: 3-4x/week, optimize based on analytics data\\n\\n';
}
// Queue summary
if (queueUpcomingCount > 0) {
context += '## Queue Summary\\n';
context += `- Queued posts (next 7 days): ${queueUpcomingCount}\\n`;
if (queueTodayCount > 0) context += `- Today: ${queueTodayCount} post(s)\\n`;
if (queueOverdueCount > 0) context += `- Overdue: ${queueOverdueCount} post(s)\\n`;
context += '- Manage: /linkedin:calendar | Publish: /linkedin:publish\\n\\n';
}
context += `State file: ${STATE_FILE}\\n`;
} else {
// Auto-initialize state file from template
const templateFile = join(PLUGIN_ROOT, 'config', 'state-file.template.md');
if (existsSync(templateFile)) {
mkdirSync(dirname(STATE_FILE), { recursive: true });
copyFileSync(templateFile, STATE_FILE);
const actualWeek = isoWeek();
let content = readFileSync(STATE_FILE, 'utf-8');
content = content.replace(/^current_week: .*/m, `current_week: "${actualWeek}"`);
writeFileSync(STATE_FILE, content);
context = `LinkedIn state file auto-initialized from template at ${STATE_FILE}.\\n`;
context += `Current ISO week set to ${actualWeek}.\\n`;
context += 'Edit the file to set your expertise_areas and weekly_goal.\\n';
} else {
context = `No LinkedIn state file found at ${STATE_FILE} and template missing.\\n`;
context += `Expected template at: ${templateFile}\\n`;
}
}
// Read REMEMBER.md for user session context
const rememberFile = join(PLUGIN_ROOT, 'REMEMBER.md');
const rememberTemplate = join(PLUGIN_ROOT, 'config', 'REMEMBER.template.md');
if (!existsSync(rememberFile) && existsSync(rememberTemplate)) {
copyFileSync(rememberTemplate, rememberFile);
let rememberContent = readFileSync(rememberFile, 'utf-8');
const today = new Date().toISOString().slice(0, 10);
rememberContent = rememberContent.replace('[Auto-filled by session-start.sh]', today);
writeFileSync(rememberFile, rememberContent);
context += '\\n## Session State\\nREMEMBER.md auto-initialized from template. Update after your first session.\\n';
} else if (existsSync(rememberFile)) {
const rememberContent = readFileSync(rememberFile, 'utf-8');
const rememberSummary = rememberContent.split('\n').slice(0, 50).join('\n');
context += `\\n## Session Context (from REMEMBER.md)\\n${rememberSummary.replace(/\n/g, '\\n')}\\n`;
}
// Output JSON for Claude Code
const output = {
continue: true,
hookSpecificOutput: {
hookEventName: 'SessionStart',
additionalContext: context.replace(/\\n/g, '\n')
}
};
process.stdout.write(JSON.stringify(output));

View file

@ -0,0 +1,90 @@
#!/usr/bin/env node
// stop-reminder.mjs
// Stop hook for linkedin-thought-leadership plugin
//
// Only fires if LinkedIn content was worked on (session marker exists).
// First stop: blocks with reason (Claude processes reminders).
// Subsequent stops within 60s: allows (prevents infinite loop).
//
// Exit codes:
// 0 - Allow (pass through or second stop)
// 2 - Not used; uses {"decision": "block"} JSON instead
import { readFileSync, writeFileSync, existsSync, statSync, unlinkSync, mkdirSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const pluginRoot = join(__dirname, '..', '..');
const promptFile = join(pluginRoot, 'hooks', 'prompts', 'state-update-reminder.md');
const sessionDir = '/tmp/linkedin-hooks';
const sessionMarker = join(sessionDir, 'session-active');
const lockFile = join(sessionDir, 'stop-hook.lock');
function nowSeconds() {
return Date.now() / 1000;
}
function fileAgeSeconds(filePath) {
try {
return nowSeconds() - statSync(filePath).mtime.getTime() / 1000;
} catch {
return Infinity;
}
}
function safeUnlink(filePath) {
try { unlinkSync(filePath); } catch { /* ignore */ }
}
// Read stdin
let input;
try {
input = JSON.parse(readFileSync(0, 'utf-8'));
} catch {
input = {};
}
// Infinite loop prevention: if Claude is already continuing from a Stop hook
if (input.stop_hook_active === true) {
process.stdout.write('{}');
process.exit(0);
}
// No session marker = no LinkedIn work done
if (!existsSync(sessionMarker)) {
process.stdout.write('{}');
process.exit(0);
}
// Staleness check: ignore markers older than 12 hours (43200 seconds)
if (fileAgeSeconds(sessionMarker) > 43200) {
safeUnlink(sessionMarker);
process.stdout.write('{}');
process.exit(0);
}
// Infinite-loop prevention: lock file within 60 seconds = second stop
if (existsSync(lockFile)) {
if (fileAgeSeconds(lockFile) < 60) {
safeUnlink(lockFile);
safeUnlink(sessionMarker);
process.stdout.write('{}');
process.exit(0);
}
safeUnlink(lockFile);
}
// First stop: create lock and block with reminder prompt
mkdirSync(sessionDir, { recursive: true });
writeFileSync(lockFile, '');
if (!existsSync(promptFile)) {
process.stdout.write('{}');
process.exit(0);
}
const promptContent = readFileSync(promptFile, 'utf-8');
process.stdout.write(JSON.stringify({ decision: 'block', reason: promptContent }));
process.exit(0);

View file

@ -0,0 +1,151 @@
#!/usr/bin/env node
// user-prompt-context.mjs
// UserPromptSubmit hook for linkedin-thought-leadership plugin
//
// Two-tier keyword matching in user prompts:
// Tier 1: Strong signals (slash commands, explicit phrases)
// Tier 2: "linkedin" + intent word, excluding plugin dev phrases
//
// When matched, injects voice profile reference, recent posts,
// planned topic, weekly progress, and quality scorecard reminder.
//
// Exit codes:
// 0 - Always allow (informational hook)
import { readFileSync, existsSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const pluginRoot = join(__dirname, '..', '..');
const home = process.env.HOME || process.env.USERPROFILE || '';
const stateFile = join(home, '.claude', 'linkedin-thought-leadership.local.md');
// Read stdin JSON
let input;
try {
input = JSON.parse(readFileSync(0, 'utf-8'));
} catch {
process.stdout.write(JSON.stringify({ continue: true }));
process.exit(0);
}
const userPrompt = (input.query ?? input.content ?? input.prompt ?? '').toLowerCase();
if (!userPrompt) {
process.stdout.write(JSON.stringify({ continue: true }));
process.exit(0);
}
// === Two-tier keyword matching ===
let isLinkedin = false;
// Tier 1: Strong signals
const strongSignals = [
'/linkedin:post', '/linkedin:quick', '/linkedin:batch',
'/linkedin:pipeline', '/linkedin:publish', '/linkedin:video',
'/linkedin:multiplatform', '/linkedin:react', '/linkedin:summarize',
'linkedin post', 'lag en post',
'skriv en post', 'write a post', 'quick post', 'create post',
'react to this', 'turn this article into',
];
for (const signal of strongSignals) {
if (userPrompt.includes(signal)) {
isLinkedin = true;
break;
}
}
// Tier 1.5: URL + intent — detect URLs with LinkedIn-relevant intent
if (!isLinkedin) {
const urlPattern = /https?:\/\/\S+/;
if (urlPattern.test(userPrompt)) {
const urlIntentWords = ['react', 'post', 'share', 'write', 'comment', 'turn', 'create', 'linkedin'];
for (const word of urlIntentWords) {
if (userPrompt.includes(word)) {
isLinkedin = true;
break;
}
}
}
}
// Tier 2: "linkedin" + intent word (excluding plugin dev phrases)
if (!isLinkedin && userPrompt.includes('linkedin')) {
const intentWords = [
'write', 'create', 'draft', 'publish', 'skriv', 'lag',
'post', 'innlegg', 'article', 'artikkel',
];
const devExclude = /(update|fix|change|modify|edit|refactor|debug|test).*(plugin|hook|script|command|agent|skill|config)/i;
for (const intent of intentWords) {
if (userPrompt.includes(intent)) {
if (!devExclude.test(userPrompt)) {
isLinkedin = true;
break;
}
}
}
}
if (!isLinkedin) {
process.stdout.write(JSON.stringify({ continue: true }));
process.exit(0);
}
// === Build context enrichment ===
let context = '**LinkedIn Context Enrichment (auto-injected):**\n\n';
// 1. Voice profile reference
const voiceFile = join(pluginRoot, 'assets', 'voice-samples', 'authentic-voice-samples.md');
if (existsSync(voiceFile)) {
context += '**Voice Profile:** Read `assets/voice-samples/authentic-voice-samples.md` for tone matching.\n\n';
}
// 2-4. State file data
if (existsSync(stateFile)) {
try {
const stateContent = readFileSync(stateFile, 'utf-8');
// Recent posts section
const recentMatch = stateContent.match(/^## Recent Posts\s*\n([\s\S]*?)(?=^## |$)/m);
if (recentMatch) {
const recentLines = recentMatch[1]
.split('\n')
.filter(l => l.trim() && !l.startsWith('<!--'))
.slice(0, 5);
if (recentLines.length > 0) {
context += `**Recent posts (avoid repetition):**\n${recentLines.join('\n')}\n\n`;
}
}
// Next planned topic from YAML frontmatter
const topicMatch = stateContent.match(/^next_planned_topic:\s*"?([^"\n]*)"?\s*$/m);
if (topicMatch && topicMatch[1].trim()) {
context += `**Planned next topic:** ${topicMatch[1].trim()}\n\n`;
}
// Weekly progress from YAML frontmatter
const postsMatch = stateContent.match(/^posts_this_week:\s*(\d+)/m);
const goalMatch = stateContent.match(/^weekly_goal:\s*(\d+)/m);
if (postsMatch && goalMatch) {
context += `**Weekly progress:** ${postsMatch[1]}/${goalMatch[1]} posts this week.\n\n`;
}
} catch {
// State file read error — skip enrichment
}
}
// 5.5 URL detection hint
const urlMatch = (input.query ?? input.content ?? input.prompt ?? '').match(/https?:\/\/\S+/);
if (urlMatch) {
context += '**URL detected:** Consider using /linkedin:react for this URL.\n\n';
}
// 5. Quality scorecard reminder
context += '**Remember:** Use `assets/checklists/quality-scorecard.md` before finalizing.\n';
process.stdout.write(JSON.stringify({ continue: true, systemMessage: context }));
process.exit(0);