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:
parent
7194a37129
commit
39f8b275a6
143 changed files with 32662 additions and 0 deletions
90
plugins/linkedin-thought-leadership/hooks/scripts/compile-hooks.py
Executable file
90
plugins/linkedin-thought-leadership/hooks/scripts/compile-hooks.py
Executable 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()
|
||||
|
|
@ -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);
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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`);
|
||||
}
|
||||
|
|
@ -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('{}');
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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');
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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));
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
Loading…
Add table
Add a link
Reference in a new issue