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,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);