refactor(linkedin)!: rename plugin linkedin-thought-leadership → linkedin-studio (v3.0.0)

BREAKING CHANGE: the marketplace slug, the agent namespace
(linkedin-studio:<agent>), and the runtime state-file path
(~/.claude/linkedin-studio.local.md) all change. Reinstall required;
existing state migrated in place (post metrics, streak, history preserved).
The /linkedin:* commands are unchanged — the command namespace is set
per-command in frontmatter and was always independent of the plugin slug.
Functionality is byte-identical to v2.4.0; this release is pure identity.

- dir + manifests: plugins/linkedin-studio + plugin.json + root marketplace.json
- agent namespace updated in commands/newsletter.md (only functional invoker)
- state path updated in 4 hook scripts + topic-rotation prompt + state template
- catch-all skill dir renamed skills/linkedin-studio (5 functional skills unchanged)
- docs + version bump to 3.0.0 across README badge, CHANGELOG, root README/CLAUDE.md
- historical records (CHANGELOG past entries, docs/ build artifacts,
  config-audit v5.0.0 snapshots) intentionally retain the old slug

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-05-29 11:32:02 +02:00
commit b6bb61246b
196 changed files with 164 additions and 138 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,85 @@
Before ending this LinkedIn content session, do two things:
**1. Update State File**
If a post was created or finalized in this session, use the state-updater script:
```bash
node --input-type=module -e "
import { writeState, updatePostTracking } from '${CLAUDE_PLUGIN_ROOT}/hooks/scripts/state-updater.mjs';
writeState(content => updatePostTracking(content, {
postDate: 'YYYY-MM-DD',
postTopic: 'topic_area',
hookText: 'First 60 chars of hook...',
charCount: NNNN,
format: 'post'
}));
"
```
Replace the placeholder values with actual post data from this session.
If the user mentioned or updated their follower count during this session:
```bash
node --input-type=module -e "
import { writeState, updateFollowerCount } from '${CLAUDE_PLUGIN_ROOT}/hooks/scripts/state-updater.mjs';
writeState(content => updateFollowerCount(content, {
count: NNNN,
month: 'YYYY-MM'
}));
"
```
- Clear `next_planned_topic` if it was used, or set it to the next suggested topic
- If analytics data was imported in this session, set `last_import_date` to today (YYYY-MM-DD) and `last_import_week` to current ISO week (YYYY-WXX)
**2. Pre-Publish Reminders** (only if a post was created)
- **Quality Check**: Has content been reviewed against quality scorecard? Hook 110-140 chars, 1,200-1,800 chars total, authentic tone, no external links.
- **5x5x5 Engagement**: Before posting, complete 15-20 min pre-posting engagement — 5 people with overlapping audiences, find their recent posts, write 5 thoughtful comments (15+ words each).
- **First-Hour Plan**: Respond within 5 minutes to first comments. Add value in responses. Target 15+ engagements in first hour.
- **Posting Time**: Post when target audience is most active.
**3. Queue Status Check**
If posts were added to the queue during this session (`assets/drafts/queue.json` was modified):
- Confirm how many posts were queued and their scheduled dates
- Remind: "View your full schedule with /linkedin:calendar"
If a scheduled post was published during this session:
- Verify it was marked as published in queue.json (status = "published")
- If not, remind: "Run /linkedin:calendar to mark the post as published and update queue status"
Provide reminders naturally based on what was done in the session. If no LinkedIn content was created, skip the reminders and just ensure state is consistent.
**4. Voice Sample Collection** (if a post was created)
If a LinkedIn post was created or finalized in this session, save the full post text as a voice sample:
- Read the full post text from the draft that was just created
- Check if `assets/voice-samples/authentic-voice-samples.md` exists
- Append the full post to the `## Collected Post Samples` section:
```
### [YYYY-MM-DD] — [post type] ([char count] chars)
[Full post text exactly as written]
```
- **Ask the user for confirmation** before writing: "I'll save this post as a voice sample for drift detection. OK?"
- This builds the voice sample library that enables automatic drift scoring (needs 5+ samples for reliable scoring)
- The more samples collected, the more accurate the voice-trainer's drift detection becomes
**5. Content History Log** (if a post was created)
If a LinkedIn post was created or finalized, append an entry to the content history log:
- If `assets/analytics/content-history.md` does not exist, initialize it from `config/content-history.template.md`
- Append a new row to the "## Content Log" table:
```
| YYYY-MM-DD | "Hook text..." | topic_area | format | word_count | char_count | source |
```
Where:
- `date`: Today's date
- `hook`: First 60 characters of the hook line
- `topic`: Matching expertise_area value (for pillar tracking)
- `format`: post/quick/react/video/pipeline
- `word_count`: Word count of the full post
- `char_count`: Character count of the full post
- `source`: original/url/curated (where the idea came from)
- This is append-only — never edit or delete existing entries
- This log enables `/linkedin:report` and `analytics-interpreter` to track content production over time without requiring LinkedIn CSV imports

View file

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

View file

@ -0,0 +1,56 @@
VOICE GUARDIAN — DRIFT SCORING & AI AUTHENTICITY CHECK: If the file being written/edited is LinkedIn content (post draft, article, or content file — NOT config, state, scripts, docs), perform both AI detection and voice drift scoring:
## 1. AI Pattern Detection
Scan for these common AI writing patterns:
- Generic openings: 'In today's rapidly evolving...', 'As we navigate...', 'In the ever-changing landscape...'
- Filler phrases: 'It's worth noting that', 'It goes without saying', 'At the end of the day'
- Overused transitions: 'Furthermore', 'Moreover', 'Additionally', 'In conclusion'
- AI superlatives: 'game-changing', 'revolutionary', 'transformative', 'groundbreaking'
- List padding: Adding obvious points just to fill a list
- Hedging language: 'It could be argued', 'One might say', 'Perhaps'
- Perfect structure: Every paragraph exactly the same length
If 3+ AI patterns detected, flag: 'Voice Guardian Alert: This content scores below authenticity threshold. AI patterns found: [list specific patterns]. Suggested fixes: [specific rewrites using natural language].'
## 2. Six-Dimension Voice Drift Scoring
Read the voice profile and collected post samples from `${CLAUDE_PLUGIN_ROOT}/assets/voice-samples/authentic-voice-samples.md`.
Score the draft against these 6 dimensions (0 = perfect match, 1 = minor drift per dimension):
| Dimension | What to Compare |
|-----------|----------------|
| **Sentence structure** | Average length, complexity, use of fragments vs. compound sentences |
| **Word choice** | Vocabulary level, preferred/avoided words from voice profile |
| **Opening patterns** | Hook style — does it match the user's signature openers? |
| **Storytelling** | Anecdote usage, narrative arc, concrete vs. abstract |
| **Tone markers** | Humor, directness, formality level, empathy signals |
| **Formatting** | Paragraph length, whitespace, emoji usage, punctuation habits |
**Sum the 6 scores (0-6 total) and output a verdict:**
| Score | Verdict | Action |
|-------|---------|--------|
| 0-1 | AUTHENTIC | No changes needed |
| 2-3 | CAUTION | Flag specific dimensions that drifted, suggest fixes |
| 4-5 | ALERT | Significant drift — list all deviating dimensions with rewrites |
| 6 | REWRITE | Content doesn't sound like the user — recommend starting over |
**Confidence gate:** If `## Collected Post Samples` has fewer than 5 posts, perform ONLY the AI Pattern Detection (section 1). Skip the Six-Dimension Voice Drift Scoring entirely — there is insufficient data for meaningful drift analysis. Do NOT output "LOW CONFIDENCE" messages. Instead, silently skip drift scoring and only flag if 3+ AI patterns are detected.
**Output format (always include at end of system message):**
```
Voice Drift: [VERDICT] ([score]/6) [confidence: HIGH/LOW]
[If CAUTION+: list dimensions that scored 1 with brief fix suggestion]
```
## 3. Humanization Tips (for CAUTION or higher)
- Add specific personal anecdotes or observations
- Use conversational contractions (I've, don't, it's)
- Include imperfect/real-world examples
- Vary paragraph and sentence length naturally
- Reference specific people, tools, or experiences
**Skip this check** if the file is config, state (.local.md), script, hook, JSON, or documentation.

View file

@ -0,0 +1,86 @@
import { describe, test } from 'node:test';
import assert from 'node:assert/strict';
import { clipboardAvailable, copyToClipboard } from '../clipboard-helper.mjs';
describe('clipboardAvailable', () => {
test('returns object with available and platform fields', () => {
const result = clipboardAvailable();
assert.equal(typeof result.available, 'boolean');
assert.equal(typeof result.platform, 'string');
});
test('returns available: true on macOS (darwin)', () => {
if (process.platform !== 'darwin') return;
const result = clipboardAvailable();
assert.equal(result.available, true);
assert.equal(result.platform, 'darwin');
});
test('returns a recognized platform string', () => {
const result = clipboardAvailable();
assert.ok(
['darwin', 'win32', 'linux'].includes(result.platform),
`Unexpected platform: ${result.platform}`
);
});
});
describe('copyToClipboard', () => {
test('returns object with success and platform fields', () => {
const result = copyToClipboard('test clipboard text');
assert.equal(typeof result.success, 'boolean');
assert.equal(typeof result.platform, 'string');
});
test('copies text successfully on macOS', () => {
if (process.platform !== 'darwin') return;
const result = copyToClipboard('clipboard-helper test 2026');
assert.equal(result.success, true);
assert.equal(result.platform, 'darwin');
});
test('handles empty string input gracefully', () => {
const result = copyToClipboard('');
assert.equal(result.success, true);
assert.equal(typeof result.platform, 'string');
});
test('handles multiline text', () => {
const multiline = 'Line 1\nLine 2\nLine 3';
const result = copyToClipboard(multiline);
assert.equal(result.success, true);
});
test('handles special characters (quotes, ampersands, backticks)', () => {
const special = 'He said "hello" & she said \'goodbye\' `code` $VAR';
const result = copyToClipboard(special);
assert.equal(result.success, true);
});
test('handles unicode/emoji text', () => {
const unicode = '🚀 Thought leadership → impact';
const result = copyToClipboard(unicode);
assert.equal(result.success, true);
});
test('never throws — always returns a result object', () => {
assert.doesNotThrow(() => copyToClipboard(null));
assert.doesNotThrow(() => copyToClipboard(undefined));
assert.doesNotThrow(() => copyToClipboard(123));
});
test('returns success: false for non-string input', () => {
const result = copyToClipboard(null);
assert.equal(result.success, false);
});
});
describe('module exports', () => {
test('exports clipboardAvailable as a function', () => {
assert.equal(typeof clipboardAvailable, 'function');
});
test('exports copyToClipboard as a function', () => {
assert.equal(typeof copyToClipboard, 'function');
});
});

View file

@ -0,0 +1,158 @@
import { describe, test } from 'node:test';
import assert from 'node:assert/strict';
import { generateIcal, generateIcalFromQueue } from '../ical-generator.mjs';
const SAMPLE_EVENT = {
id: 'post-2026-04-14-ai-strategy',
title: 'LinkedIn: AI strategy in public sector',
description: 'Pillar: AI Strategy | Format: Standard | Draft: assets/drafts/week-W16/monday.md',
date: '2026-04-14',
time: '08:30',
duration: 30,
};
const SAMPLE_QUEUE_ENTRY = {
id: 'post-2026-04-14-ai-strategy',
draft_path: 'assets/drafts/week-W16/monday.md',
scheduled_date: '2026-04-14',
scheduled_time: '08:30',
pillar: 'AI Strategy',
format: 'Standard',
hook_preview: 'AI strategy in public sector',
character_count: 1450,
status: 'scheduled',
created_at: '2026-04-10',
};
describe('generateIcal', () => {
test('returns valid empty VCALENDAR for empty events array', () => {
const ical = generateIcal([]);
assert.match(ical, /^BEGIN:VCALENDAR\r\n/);
assert.match(ical, /\r\nEND:VCALENDAR\r\n$/);
assert.match(ical, /PRODID:-\/\/linkedin-studio\/\/EN/);
assert.match(ical, /VERSION:2\.0/);
assert.ok(!ical.includes('BEGIN:VEVENT'), 'should not contain VEVENT');
});
test('generates VEVENT with correct DTSTART, SUMMARY, UID', () => {
const ical = generateIcal([SAMPLE_EVENT]);
assert.match(ical, /BEGIN:VEVENT/);
assert.match(ical, /DTSTART;TZID=Europe\/Oslo:20260414T083000/);
assert.match(ical, /SUMMARY:LinkedIn: AI strategy in public sector/);
assert.match(ical, /UID:post-2026-04-14-ai-strategy@linkedin-studio/);
assert.match(ical, /END:VEVENT/);
});
test('generates correct DTEND from duration', () => {
const ical = generateIcal([SAMPLE_EVENT]);
assert.match(ical, /DTEND;TZID=Europe\/Oslo:20260414T090000/);
});
test('defaults duration to 30 minutes when not specified', () => {
const event = { ...SAMPLE_EVENT, duration: undefined };
const ical = generateIcal([event]);
assert.match(ical, /DTEND;TZID=Europe\/Oslo:20260414T090000/);
});
test('has CRLF line endings throughout', () => {
const ical = generateIcal([SAMPLE_EVENT]);
const lines = ical.split('\r\n');
assert.ok(lines.length > 5, 'should have multiple lines');
const bareLF = ical.replace(/\r\n/g, '').includes('\n');
assert.ok(!bareLF, 'should not contain bare LF without CR');
});
test('includes VALARM with 15-minute trigger', () => {
const ical = generateIcal([SAMPLE_EVENT]);
assert.match(ical, /BEGIN:VALARM/);
assert.match(ical, /TRIGGER:-PT15M/);
assert.match(ical, /ACTION:DISPLAY/);
assert.match(ical, /END:VALARM/);
});
test('includes DTSTAMP in UTC format', () => {
const ical = generateIcal([SAMPLE_EVENT]);
assert.match(ical, /DTSTAMP:\d{8}T\d{6}Z/);
});
test('folds lines longer than 75 octets', () => {
const longDescription = 'A'.repeat(200);
const event = { ...SAMPLE_EVENT, description: longDescription };
const ical = generateIcal([event]);
const lines = ical.split('\r\n');
for (const line of lines) {
const octets = Buffer.byteLength(line, 'utf-8');
assert.ok(octets <= 75, `Line exceeds 75 octets (${octets}): "${line.slice(0, 40)}..."`);
}
});
test('escapes special characters in SUMMARY and DESCRIPTION', () => {
const event = {
...SAMPLE_EVENT,
title: 'Test: commas, semicolons; and\\backslashes',
description: 'Line1\nLine2, with; special\\chars',
};
const ical = generateIcal([event]);
assert.match(ical, /SUMMARY:Test: commas\\, semicolons\; and\\\\backslashes/);
assert.match(ical, /DESCRIPTION:Line1\\nLine2\\, with\; special\\\\chars/);
});
test('handles multiple events', () => {
const event2 = {
...SAMPLE_EVENT,
id: 'post-2026-04-16-leadership',
title: 'LinkedIn: Leadership lessons',
date: '2026-04-16',
time: '12:00',
};
const ical = generateIcal([SAMPLE_EVENT, event2]);
const veventCount = (ical.match(/BEGIN:VEVENT/g) || []).length;
assert.equal(veventCount, 2);
});
test('includes VTIMEZONE for Europe/Oslo', () => {
const ical = generateIcal([SAMPLE_EVENT]);
assert.match(ical, /BEGIN:VTIMEZONE/);
assert.match(ical, /TZID:Europe\/Oslo/);
assert.match(ical, /END:VTIMEZONE/);
});
test('supports custom timezone parameter', () => {
const ical = generateIcal([SAMPLE_EVENT], { timezone: 'America/New_York' });
assert.match(ical, /TZID:America\/New_York/);
assert.match(ical, /DTSTART;TZID=America\/New_York/);
});
});
describe('generateIcalFromQueue', () => {
test('transforms queue entry format to event format', () => {
const events = generateIcalFromQueue([SAMPLE_QUEUE_ENTRY]);
assert.equal(events.length, 1);
const e = events[0];
assert.equal(e.id, 'post-2026-04-14-ai-strategy');
assert.equal(e.date, '2026-04-14');
assert.equal(e.time, '08:30');
assert.ok(e.title.includes('AI strategy in public sector'));
assert.ok(e.description.includes('AI Strategy'));
assert.ok(e.description.includes('Standard'));
});
test('handles missing scheduled_time gracefully', () => {
const entry = { ...SAMPLE_QUEUE_ENTRY, scheduled_time: undefined };
const events = generateIcalFromQueue([entry]);
assert.equal(events[0].time, '09:00');
});
test('handles empty array', () => {
const events = generateIcalFromQueue([]);
assert.deepEqual(events, []);
});
test('generates valid iCal when piped through generateIcal', () => {
const events = generateIcalFromQueue([SAMPLE_QUEUE_ENTRY]);
const ical = generateIcal(events);
assert.match(ical, /BEGIN:VCALENDAR/);
assert.match(ical, /BEGIN:VEVENT/);
assert.match(ical, /END:VCALENDAR/);
});
});

View file

@ -0,0 +1,295 @@
import { describe, test } from 'node:test';
import assert from 'node:assert/strict';
import { updatePostTracking, pruneContentHistory, updateFollowerCount } from '../state-updater.mjs';
const SAMPLE_STATE = `---
last_post_date: "2026-04-05"
first_post_date: "2026-01-15"
last_post_topic: "AI strategy"
posts_this_week: 2
weekly_goal: 3
current_streak: 5
longest_streak: 12
current_week: "2026-W14"
last_import_date: "2026-04-01"
last_import_week: "2026-W14"
follower_count: 850
follower_target: 10000
target_date: "2026-12-31"
monthly_growth: []
projected_10k_date: ""
growth_rate_needed: 0
---
# LinkedIn Session State
## Recent Posts
- [2026-04-05] "AI governance is not about..." (1450) - AI strategy
- [2026-04-03] "Three things I learned..." (1200) - leadership
- [2026-03-28] "Why most teams fail at..." (1350) - team building
## Session Notes
## Planned Content
## Milestone Log
`;
describe('updatePostTracking', () => {
test('sets last_post_date to provided date', () => {
const result = updatePostTracking(SAMPLE_STATE, {
postDate: '2026-04-07',
postTopic: 'AI governance',
hookText: 'The real problem with AI governance...',
charCount: 1500,
format: 'post'
});
assert.notEqual(result, null);
assert.match(result.content, /^last_post_date: "2026-04-07"$/m);
});
test('sets last_post_topic', () => {
const result = updatePostTracking(SAMPLE_STATE, {
postDate: '2026-04-07',
postTopic: 'AI governance',
hookText: 'The real problem...',
charCount: 1500,
format: 'post'
});
assert.match(result.content, /^last_post_topic: "AI governance"$/m);
});
test('increments posts_this_week when same week', () => {
// 2026-04-06 is a Monday, ISO W15. current_week is W14.
// Use a date that stays in W14: 2026-04-05 is Sunday W14 — but last_post_date is already 04-05.
// Let's use a state with current_week matching the post date week.
const w15State = SAMPLE_STATE.replace('current_week: "2026-W14"', 'current_week: "2026-W15"');
const result = updatePostTracking(w15State, {
postDate: '2026-04-07', // Tuesday W15
postTopic: 'test',
hookText: 'Hook',
charCount: 1000,
format: 'post'
});
assert.notEqual(result, null);
assert.match(result.content, /^posts_this_week: 3$/m); // was 2, incremented
});
test('increments streak when gap <= 2 days', () => {
const result = updatePostTracking(SAMPLE_STATE, {
postDate: '2026-04-06', // 1 day after last_post_date 2026-04-05
postTopic: 'test',
hookText: 'Hook',
charCount: 1000,
format: 'post'
});
assert.notEqual(result, null);
assert.match(result.content, /^current_streak: 6$/m); // was 5, incremented
});
test('resets streak to 1 when gap > 2 days', () => {
const result = updatePostTracking(SAMPLE_STATE, {
postDate: '2026-04-09', // 4 days after 2026-04-05
postTopic: 'test',
hookText: 'Hook',
charCount: 1000,
format: 'post'
});
assert.notEqual(result, null);
assert.match(result.content, /^current_streak: 1$/m);
});
test('sets first_post_date when null', () => {
const nullFirstPost = SAMPLE_STATE.replace(
'first_post_date: "2026-01-15"',
'first_post_date: null'
);
const result = updatePostTracking(nullFirstPost, {
postDate: '2026-04-07',
postTopic: 'test',
hookText: 'Hook',
charCount: 1000,
format: 'post'
});
assert.notEqual(result, null);
assert.match(result.content, /^first_post_date: "2026-04-07"$/m);
});
test('does NOT overwrite existing first_post_date', () => {
const result = updatePostTracking(SAMPLE_STATE, {
postDate: '2026-04-07',
postTopic: 'test',
hookText: 'Hook',
charCount: 1000,
format: 'post'
});
assert.notEqual(result, null);
assert.match(result.content, /^first_post_date: "2026-01-15"$/m);
});
test('triggers week rollover when ISO week changes', () => {
// 2026-04-14 is W16, current_week is W14
const result = updatePostTracking(SAMPLE_STATE, {
postDate: '2026-04-14',
postTopic: 'test',
hookText: 'Hook',
charCount: 1000,
format: 'post'
});
assert.notEqual(result, null);
// After rollover, posts_this_week resets to 0 then increments to 1
assert.match(result.content, /^posts_this_week: 1$/m);
assert.match(result.content, /^current_week: "2026-W16"$/m);
});
test('appends to Recent Posts section', () => {
const result = updatePostTracking(SAMPLE_STATE, {
postDate: '2026-04-06',
postTopic: 'AI governance',
hookText: 'The real problem with AI governance today...',
charCount: 1500,
format: 'post'
});
assert.notEqual(result, null);
assert.ok(result.content.includes('- [2026-04-06] "The real problem with AI governance today..." (1500) - AI governance'));
// Existing entries should still be there
assert.ok(result.content.includes('- [2026-04-05] "AI governance is not about..."'));
});
test('updates longest_streak when current exceeds it', () => {
const highStreak = SAMPLE_STATE.replace('current_streak: 5', 'current_streak: 12');
const result = updatePostTracking(highStreak, {
postDate: '2026-04-06', // 1 day gap, streak increments to 13
postTopic: 'test',
hookText: 'Hook',
charCount: 1000,
format: 'post'
});
assert.notEqual(result, null);
assert.match(result.content, /^current_streak: 13$/m);
assert.match(result.content, /^longest_streak: 13$/m);
});
test('does not update longest_streak when current is lower', () => {
const result = updatePostTracking(SAMPLE_STATE, {
postDate: '2026-04-06',
postTopic: 'test',
hookText: 'Hook',
charCount: 1000,
format: 'post'
});
assert.notEqual(result, null);
assert.match(result.content, /^current_streak: 6$/m);
assert.match(result.content, /^longest_streak: 12$/m); // unchanged
});
test('returns changes array describing what changed', () => {
const result = updatePostTracking(SAMPLE_STATE, {
postDate: '2026-04-06',
postTopic: 'AI governance',
hookText: 'Hook',
charCount: 1500,
format: 'post'
});
assert.notEqual(result, null);
assert.ok(Array.isArray(result.changes));
assert.ok(result.changes.length > 0);
});
});
describe('pruneContentHistory', () => {
test('removes entries older than 90 days', () => {
const today = new Date();
const old = new Date(today);
old.setDate(old.getDate() - 100);
const oldDate = old.toISOString().slice(0, 10);
const recent = new Date(today);
recent.setDate(recent.getDate() - 10);
const recentDate = recent.toISOString().slice(0, 10);
const stateWithOld = SAMPLE_STATE.replace(
'## Recent Posts\n\n',
`## Recent Posts\n\n- [${oldDate}] "Old post..." (1000) - old topic\n- [${recentDate}] "Recent post..." (1200) - recent topic\n`
);
const result = pruneContentHistory(stateWithOld, 90);
assert.notEqual(result, null);
assert.equal(result.pruned, 1);
assert.ok(!result.content.includes(oldDate));
assert.ok(result.content.includes(recentDate));
});
test('preserves entries within 90 days', () => {
const today = new Date();
const recent = new Date(today);
recent.setDate(recent.getDate() - 30);
const recentDate = recent.toISOString().slice(0, 10);
const stateWithRecent = SAMPLE_STATE.replace(
'## Recent Posts\n\n',
`## Recent Posts\n\n- [${recentDate}] "Recent post..." (1200) - topic\n`
);
const result = pruneContentHistory(stateWithRecent, 90);
assert.equal(result, null); // nothing to prune
});
test('returns null when no entries exist', () => {
const emptyRecent = SAMPLE_STATE.replace(
/## Recent Posts\n\n[\s\S]*?(?=## Session Notes)/,
'## Recent Posts\n\n'
);
const result = pruneContentHistory(emptyRecent, 90);
assert.equal(result, null);
});
test('handles custom maxAgeDays', () => {
const today = new Date();
const old = new Date(today);
old.setDate(old.getDate() - 40);
const oldDate = old.toISOString().slice(0, 10);
const stateWithOld = SAMPLE_STATE.replace(
'## Recent Posts\n\n',
`## Recent Posts\n\n- [${oldDate}] "Somewhat old..." (1000) - topic\n`
);
const result = pruneContentHistory(stateWithOld, 30);
assert.notEqual(result, null);
assert.equal(result.pruned, 1);
});
});
describe('updateFollowerCount', () => {
test('updates follower_count', () => {
const result = updateFollowerCount(SAMPLE_STATE, {
count: 920,
month: '2026-04'
});
assert.notEqual(result, null);
assert.match(result.content, /^follower_count: 920$/m);
});
test('recalculates growth_rate_needed', () => {
const result = updateFollowerCount(SAMPLE_STATE, {
count: 920,
month: '2026-04'
});
assert.notEqual(result, null);
const match = result.content.match(/^growth_rate_needed: (\d+)$/m);
assert.ok(match, 'growth_rate_needed should be present');
const rate = parseInt(match[1], 10);
assert.ok(rate > 0, 'growth_rate_needed should be positive');
});
test('appends to Milestone Log section', () => {
const result = updateFollowerCount(SAMPLE_STATE, {
count: 920,
month: '2026-04'
});
assert.notEqual(result, null);
assert.ok(result.content.includes('[2026-04] 920 (+70)'));
});
});

View file

@ -0,0 +1,102 @@
import { describe, test } from 'node:test';
import assert from 'node:assert/strict';
import { applyWeekRollover } from '../week-rollover.mjs';
const SAMPLE_STATE = `---
last_post_date: "2026-04-05"
first_post_date: "2026-01-15"
last_post_topic: "AI strategy"
posts_this_week: 3
weekly_goal: 3
current_streak: 5
longest_streak: 12
current_week: "2026-W14"
last_import_date: "2026-04-01"
follower_count: 850
follower_target: 10000
target_date: "2026-12-31"
---
## Recent Posts
- 2026-04-05: AI strategy post
`;
describe('applyWeekRollover', () => {
test('resets posts_this_week to 0 on week change', () => {
const result = applyWeekRollover(SAMPLE_STATE, '2026-W14', '2026-W15');
assert.notEqual(result, null);
assert.match(result.content, /^posts_this_week: 0$/m);
});
test('updates current_week to new week', () => {
const result = applyWeekRollover(SAMPLE_STATE, '2026-W14', '2026-W15');
assert.notEqual(result, null);
assert.match(result.content, /^current_week: "2026-W15"$/m);
});
test('returns descriptive message on rollover', () => {
const result = applyWeekRollover(SAMPLE_STATE, '2026-W14', '2026-W15');
assert.notEqual(result, null);
assert.ok(result.message.includes('2026-W15'));
assert.ok(result.message.includes('2026-W14'));
});
test('returns null when week matches (no change needed)', () => {
const result = applyWeekRollover(SAMPLE_STATE, '2026-W14', '2026-W14');
assert.equal(result, null);
});
test('preserves all other YAML fields unchanged', () => {
const result = applyWeekRollover(SAMPLE_STATE, '2026-W14', '2026-W15');
assert.notEqual(result, null);
assert.match(result.content, /^last_post_date: "2026-04-05"$/m);
assert.match(result.content, /^current_streak: 5$/m);
assert.match(result.content, /^weekly_goal: 3$/m);
assert.match(result.content, /^follower_count: 850$/m);
});
test('preserves markdown body after frontmatter', () => {
const result = applyWeekRollover(SAMPLE_STATE, '2026-W14', '2026-W15');
assert.notEqual(result, null);
assert.ok(result.content.includes('## Recent Posts'));
assert.ok(result.content.includes('AI strategy post'));
});
test('initializes current_week when empty without resetting posts', () => {
const stateWithEmptyWeek = SAMPLE_STATE.replace(
'current_week: "2026-W14"',
'current_week: ""'
);
const result = applyWeekRollover(stateWithEmptyWeek, '', '2026-W15');
assert.notEqual(result, null);
assert.match(result.content, /^current_week: "2026-W15"$/m);
// posts_this_week should NOT be reset (user may have manually tracked)
assert.match(result.content, /^posts_this_week: 3$/m);
});
test('returns null when actualWeek is empty', () => {
const result = applyWeekRollover(SAMPLE_STATE, '2026-W14', '');
assert.equal(result, null);
});
test('returns null when actualWeek is null/undefined', () => {
assert.equal(applyWeekRollover(SAMPLE_STATE, '2026-W14', null), null);
assert.equal(applyWeekRollover(SAMPLE_STATE, '2026-W14', undefined), null);
});
test('handles year boundary rollover (W52 → W01)', () => {
const yearEndState = SAMPLE_STATE.replace('2026-W14', '2025-W52');
const result = applyWeekRollover(yearEndState, '2025-W52', '2026-W01');
assert.notEqual(result, null);
assert.match(result.content, /^posts_this_week: 0$/m);
assert.match(result.content, /^current_week: "2026-W01"$/m);
});
test('handles posts_this_week already at 0', () => {
const zeroPostsState = SAMPLE_STATE.replace('posts_this_week: 3', 'posts_this_week: 0');
const result = applyWeekRollover(zeroPostsState, '2026-W14', '2026-W15');
assert.notEqual(result, null);
assert.match(result.content, /^posts_this_week: 0$/m);
assert.match(result.content, /^current_week: "2026-W15"$/m);
});
});

View file

@ -0,0 +1,102 @@
#!/usr/bin/env node
// Cross-platform clipboard helper for linkedin-studio plugin
// Copies text to system clipboard using platform-native commands.
// Standalone: reads stdin and copies it. Import: export { copyToClipboard, clipboardAvailable }
import { execSync } from 'node:child_process';
import { dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const PLATFORM_COMMANDS = {
darwin: { copy: 'pbcopy', check: 'which pbcopy' },
win32: { copy: 'clip', check: 'where clip' },
linux: { copy: 'xclip -selection clipboard', check: 'which xclip' },
};
const LINUX_FALLBACK = { copy: 'xsel --clipboard --input', check: 'which xsel' };
/**
* Check if clipboard is available on this platform.
* @returns {{ available: boolean, platform: string }}
*/
export function clipboardAvailable() {
const platform = process.platform;
const commands = PLATFORM_COMMANDS[platform];
if (!commands) {
return { available: false, platform };
}
try {
execSync(commands.check, { stdio: 'ignore' });
return { available: true, platform };
} catch {
// Linux fallback: try xsel if xclip not found
if (platform === 'linux') {
try {
execSync(LINUX_FALLBACK.check, { stdio: 'ignore' });
return { available: true, platform };
} catch {
return { available: false, platform };
}
}
return { available: false, platform };
}
}
/**
* Copy text to the system clipboard.
* Never throws always returns a result object.
* @param {string} text - The text to copy
* @returns {{ success: boolean, platform: string }}
*/
export function copyToClipboard(text) {
const platform = process.platform;
if (typeof text !== 'string') {
return { success: false, platform };
}
const commands = PLATFORM_COMMANDS[platform];
if (!commands) {
return { success: false, platform };
}
// Determine which copy command to use
let copyCmd = commands.copy;
if (platform === 'linux') {
try {
execSync(commands.check, { stdio: 'ignore' });
} catch {
try {
execSync(LINUX_FALLBACK.check, { stdio: 'ignore' });
copyCmd = LINUX_FALLBACK.copy;
} catch {
return { success: false, platform };
}
}
}
try {
execSync(copyCmd, { input: text, stdio: ['pipe', 'ignore', 'ignore'] });
return { success: true, platform };
} catch {
return { success: false, platform };
}
}
// Standalone execution: read stdin and copy
if (process.argv[1] === fileURLToPath(import.meta.url)) {
let input = '';
process.stdin.setEncoding('utf-8');
process.stdin.on('data', (chunk) => { input += chunk; });
process.stdin.on('end', () => {
const result = copyToClipboard(input);
if (result.success) {
process.stdout.write('COPIED\n');
} else {
process.stdout.write(`FAILED:${result.platform}\n`);
process.exitCode = 1;
}
});
}

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-studio 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,231 @@
#!/usr/bin/env node
// RFC 5545 iCal generator for linkedin-studio plugin
// Import: import { generateIcal, generateIcalFromQueue, writeIcalFile } from './ical-generator.mjs';
// Standalone: node ical-generator.mjs --from-queue --output path/to/schedule.ics
import { writeFileSync, readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const CRLF = '\r\n';
/**
* Escape text values per RFC 5545 Section 3.3.11.
* Backslashes first (to avoid double-escaping), then semicolons, commas, newlines.
*/
function escapeText(str) {
if (!str) return '';
return str
.replace(/\\/g, '\\\\')
.replace(/;/g, '\;')
.replace(/,/g, '\\,')
.replace(/\n/g, '\\n');
}
/**
* Fold a content line per RFC 5545 Section 3.1.
* Lines MUST NOT be longer than 75 octets. Long lines are folded by
* inserting a CRLF followed by a single whitespace character (space).
*/
function foldLine(line) {
const maxOctets = 75;
if (Buffer.byteLength(line, 'utf-8') <= maxOctets) return line;
const parts = [];
let remaining = line;
let isFirst = true;
while (Buffer.byteLength(remaining, 'utf-8') > maxOctets) {
// Find the split point: max octets for first line, max-1 for continuations (leading space)
const limit = isFirst ? maxOctets : maxOctets - 1;
let splitAt = 0;
let octetCount = 0;
for (let i = 0; i < remaining.length; i++) {
const charOctets = Buffer.byteLength(remaining[i], 'utf-8');
if (octetCount + charOctets > limit) break;
octetCount += charOctets;
splitAt = i + 1;
}
parts.push((isFirst ? '' : ' ') + remaining.slice(0, splitAt));
remaining = remaining.slice(splitAt);
isFirst = false;
}
if (remaining.length > 0) {
parts.push((isFirst ? '' : ' ') + remaining);
}
return parts.join(CRLF);
}
/**
* Format a Date as iCal UTC timestamp: YYYYMMDDTHHmmssZ
*/
function formatUtcTimestamp(date) {
const d = date || new Date();
const pad = (n) => String(n).padStart(2, '0');
return `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}T${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}Z`;
}
/**
* Format date + time as iCal local datetime: YYYYMMDDTHHMMSS
*/
function formatLocalDatetime(dateStr, timeStr) {
const [y, m, d] = dateStr.split('-');
const [h, min] = (timeStr || '09:00').split(':');
return `${y}${m}${d}T${h}${min}00`;
}
/**
* Add minutes to a time string (HH:MM), returns new time as HHMMSS for iCal.
* Handles day overflow simply by capping at 23:59.
*/
function addMinutes(dateStr, timeStr, minutes) {
const [y, m, d] = dateStr.split('-').map(Number);
const [h, min] = (timeStr || '09:00').split(':').map(Number);
const totalMin = h * 60 + min + minutes;
const newH = Math.min(Math.floor(totalMin / 60), 23);
const newMin = totalMin % 60;
const pad = (n) => String(n).padStart(2, '0');
return `${pad(y)}${pad(m)}${pad(d)}T${pad(newH)}${pad(newMin)}00`;
}
/**
* Generate a minimal VTIMEZONE component.
* Full Olson TZ database support is out of scope; we provide the structural
* component so calendar apps recognize the TZID reference.
*/
function generateVtimezone(timezone) {
const lines = [
'BEGIN:VTIMEZONE',
`TZID:${timezone}`,
'BEGIN:STANDARD',
`DTSTART:19701025T030000`,
'RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10',
`TZOFFSETFROM:+0200`,
`TZOFFSETTO:+0100`,
`TZNAME:CET`,
'END:STANDARD',
'BEGIN:DAYLIGHT',
`DTSTART:19700329T020000`,
'RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3',
`TZOFFSETFROM:+0100`,
`TZOFFSETTO:+0200`,
`TZNAME:CEST`,
'END:DAYLIGHT',
'END:VTIMEZONE',
];
return lines;
}
/**
* Generate RFC 5545 compliant iCal string from event objects.
*
* @param {Array<{id, title, description, date, time, duration}>} events
* @param {Object} [options]
* @param {string} [options.timezone='Europe/Oslo'] - TZID for DTSTART/DTEND
* @returns {string} Valid .ics file content with CRLF line endings
*/
export function generateIcal(events, options = {}) {
const tz = options.timezone || 'Europe/Oslo';
const now = formatUtcTimestamp(new Date());
const lines = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//linkedin-studio//EN',
'CALSCALE:GREGORIAN',
'METHOD:PUBLISH',
];
// Add VTIMEZONE if we have events
if (events.length > 0) {
lines.push(...generateVtimezone(tz));
}
for (const event of events) {
const duration = event.duration || 30;
const dtstart = formatLocalDatetime(event.date, event.time);
const dtend = addMinutes(event.date, event.time, duration);
lines.push(
'BEGIN:VEVENT',
`UID:${event.id}@linkedin-studio`,
`DTSTAMP:${now}`,
`DTSTART;TZID=${tz}:${dtstart}`,
`DTEND;TZID=${tz}:${dtend}`,
`SUMMARY:${escapeText(event.title)}`,
`DESCRIPTION:${escapeText(event.description || '')}`,
'BEGIN:VALARM',
'TRIGGER:-PT15M',
'ACTION:DISPLAY',
`DESCRIPTION:Reminder: ${escapeText(event.title)}`,
'END:VALARM',
'END:VEVENT',
);
}
lines.push('END:VCALENDAR');
// Apply line folding and join with CRLF
return lines.map(foldLine).join(CRLF) + CRLF;
}
/**
* Transform queue entries (from queue-manager.mjs) into event format.
*
* @param {Array<{id, draft_path, scheduled_date, scheduled_time, pillar, format, hook_preview}>} queueEntries
* @returns {Array<{id, title, description, date, time, duration}>}
*/
export function generateIcalFromQueue(queueEntries) {
return queueEntries.map(entry => ({
id: entry.id,
title: `LinkedIn: ${entry.hook_preview || 'Scheduled post'}`,
description: `Pillar: ${entry.pillar || '?'} | Format: ${entry.format || '?'} | Draft: ${entry.draft_path || '?'}`,
date: entry.scheduled_date,
time: entry.scheduled_time || '09:00',
duration: 30,
}));
}
/**
* Write .ics file to disk.
*
* @param {string} outputPath - Path to write the .ics file
* @param {Array} events - Event objects (from generateIcalFromQueue or direct)
* @param {Object} [options] - Options passed to generateIcal
*/
export function writeIcalFile(outputPath, events, options) {
const ical = generateIcal(events, options);
writeFileSync(outputPath, ical, 'utf-8');
return outputPath;
}
// Standalone CLI mode
if (process.argv[1] && process.argv[1].endsWith('ical-generator.mjs')) {
const args = process.argv.slice(2);
const fromQueue = args.includes('--from-queue');
const outputIdx = args.indexOf('--output');
const outputPath = outputIdx >= 0 ? args[outputIdx + 1] : null;
if (!fromQueue || !outputPath) {
console.log('Usage: node ical-generator.mjs --from-queue --output path/to/schedule.ics');
process.exit(1);
}
// Dynamic import to avoid circular dep issues
const { queueUpcoming } = await import('./queue-manager.mjs');
const upcoming = queueUpcoming(14);
if (upcoming.length === 0) {
console.log('No upcoming scheduled posts in queue.');
process.exit(0);
}
const events = generateIcalFromQueue(upcoming);
writeIcalFile(outputPath, events);
console.log(`Calendar file: ${outputPath} (${events.length} events)`);
}

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-studio/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-studio 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-studio 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-studio.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:calendar after posting to mark as published.`);
if (overdueEntries.length > 0) reminders.push(`${overdueEntries.length} overdue post(s) in your queue. Run /linkedin:calendar to mark as posted or 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-studio 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-studio 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-studio 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,433 @@
#!/usr/bin/env node
// SessionStart hook for linkedin-studio 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';
import { applyWeekRollover } from './week-rollover.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-studio.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 — auto-reset posts_this_week on week change
const actualWeek = isoWeek();
let weekResetNote = '';
try {
const rollover = applyWeekRollover(stateContent, currentWeek, actualWeek);
if (rollover) {
writeFileSync(STATE_FILE, rollover.content, 'utf-8');
weekResetNote = rollover.message;
}
} catch (err) {
weekResetNote = `Warning: Week rollover failed (${err.message}). Manual reset may be needed.`;
}
// Auto-prune Recent Posts entries older than 90 days
try {
const currentState = readFileSync(STATE_FILE, 'utf-8');
const { pruneContentHistory } = await import('./state-updater.mjs');
const pruneResult = pruneContentHistory(currentState, 90);
if (pruneResult && pruneResult.pruned > 0) {
writeFileSync(STATE_FILE, pruneResult.content, 'utf-8');
weekResetNote += (weekResetNote ? ' ' : '') + `Auto-pruned ${pruneResult.pruned} posts older than 90 days from Recent Posts.`;
}
} catch {
// Non-critical: don't block session start on pruning failure
}
// Count published posts for progressive onboarding
const recentPostsSection = stateContent.match(/^## Recent Posts\n([\s\S]*?)(?=\n## [^R]|\n## $|$)/m);
let publishedPostCount = 0;
if (recentPostsSection) {
publishedPostCount = (recentPostsSection[1].match(/^\s*[-\[]/gm) || []).length;
}
// 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 (only show after 3+ published posts — progressive onboarding)
let pScore = null;
try {
const { score } = calculateScore(PLUGIN_ROOT);
pScore = score;
if (publishedPostCount >= 3) {
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 Studio 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:calendar after posting to mark as published.\\n\\n`;
}
// Overdue posts
if (queueOverdueText) {
context += `## OVERDUE Posts\\n${queueOverdueText.replace(/\n/g, '\\n')}\\nRun /linkedin:calendar to mark as posted or 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 (only after 3+ posts — progressive onboarding)
if (pScore !== null && pScore < 50 && publishedPostCount >= 3) {
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:calendar after posting to mark as published.\\n`;
}
if (queueOverdueCount > 0) {
reminders += `- ${queueOverdueCount} overdue post(s) in queue. Run /linkedin:calendar to mark as posted or reschedule.\\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 + publish: /linkedin:calendar\\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,253 @@
// Deterministic state mutation functions for linkedin-studio plugin.
// Pure functions operate on string content (same pattern as week-rollover.mjs).
// I/O wrapper (writeState) handles file reads/writes (same pattern as queue-manager.mjs).
import { readFileSync, writeFileSync, renameSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { applyWeekRollover } from './week-rollover.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const HOME = process.env.HOME || process.env.USERPROFILE || '';
const STATE_FILE = process.env.STATE_FILE || join(HOME, '.claude', 'linkedin-studio.local.md');
function replaceField(content, field, value) {
return content.replace(
new RegExp(`^${field}: .*`, 'm'),
`${field}: ${value}`
);
}
function isoWeekFromDate(dateStr) {
const d = new Date(dateStr + 'T12:00:00Z');
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 daysBetween(dateA, dateB) {
const a = new Date(dateA + 'T12:00:00Z').getTime();
const b = new Date(dateB + 'T12:00:00Z').getTime();
if (isNaN(a) || isNaN(b)) return null;
return Math.abs(Math.round((b - a) / 86400000));
}
function extractField(content, field) {
const re = new RegExp(`^${field}: *"?([^"\\n]*)"?`, 'm');
const m = content.match(re);
return m ? m[1].trim() : '';
}
/**
* Update post tracking fields deterministically.
* @param {string} stateContent - Full state file content
* @param {{ postDate: string, postTopic: string, hookText: string, charCount: number, format: string }} opts
* @returns {{ content: string, changes: string[] } | null}
*/
export function updatePostTracking(stateContent, { postDate, postTopic, hookText, charCount, format }) {
let content = stateContent;
const changes = [];
// 1. Update last_post_date
content = replaceField(content, 'last_post_date', `"${postDate}"`);
changes.push(`last_post_date → ${postDate}`);
// 2. Update last_post_topic
content = replaceField(content, 'last_post_topic', `"${postTopic}"`);
changes.push(`last_post_topic → ${postTopic}`);
// 3. Set first_post_date if null
const existingFirst = extractField(content, 'first_post_date');
if (!existingFirst || existingFirst === 'null') {
content = replaceField(content, 'first_post_date', `"${postDate}"`);
changes.push(`first_post_date → ${postDate} (first post!)`);
}
// 4. Week rollover — check if ISO week changed
const currentWeek = extractField(content, 'current_week');
const postWeek = isoWeekFromDate(postDate);
const rollover = applyWeekRollover(content, currentWeek, postWeek);
if (rollover) {
content = rollover.content;
changes.push(rollover.message);
}
// 5. Increment posts_this_week
const currentPosts = parseInt(extractField(content, 'posts_this_week') || '0', 10);
content = replaceField(content, 'posts_this_week', String(currentPosts + 1));
changes.push(`posts_this_week → ${currentPosts + 1}`);
// 6. Update streak
const lastPostDate = extractField(stateContent, 'last_post_date');
let currentStreak = parseInt(extractField(content, 'current_streak') || '0', 10);
if (lastPostDate && lastPostDate !== 'null') {
const gap = daysBetween(lastPostDate, postDate);
if (gap !== null && gap <= 2) {
currentStreak += 1;
changes.push(`current_streak → ${currentStreak} (gap: ${gap}d)`);
} else {
currentStreak = 1;
changes.push(`current_streak → 1 (gap: ${gap}d, reset)`);
}
} else {
currentStreak = 1;
changes.push('current_streak → 1 (first post)');
}
content = replaceField(content, 'current_streak', String(currentStreak));
// 7. Update longest_streak if exceeded
const longestStreak = parseInt(extractField(content, 'longest_streak') || '0', 10);
if (currentStreak > longestStreak) {
content = replaceField(content, 'longest_streak', String(currentStreak));
changes.push(`longest_streak → ${currentStreak}`);
}
// 8. Append to Recent Posts section
const hookPreview = hookText.length > 60 ? hookText.slice(0, 57) + '...' : hookText;
const entry = `- [${postDate}] "${hookPreview}" (${charCount}) - ${postTopic}`;
content = content.replace(
/^(## Recent Posts\n\n?)/m,
`$1${entry}\n`
);
changes.push(`Recent Posts += ${postDate} "${hookPreview.slice(0, 30)}..."`);
if (content === stateContent) return null;
return { content, changes };
}
/**
* Remove Recent Posts entries older than maxAgeDays.
* @param {string} stateContent - Full state file content
* @param {number} [maxAgeDays=90]
* @returns {{ content: string, pruned: number } | null}
*/
export function pruneContentHistory(stateContent, maxAgeDays = 90) {
const today = new Date();
const cutoff = new Date(today);
cutoff.setDate(cutoff.getDate() - maxAgeDays);
const cutoffStr = cutoff.toISOString().slice(0, 10);
// Find all Recent Posts entries
const entryPattern = /^- \[(\d{4}-\d{2}-\d{2})\] .+$/gm;
const recentSection = stateContent.match(/## Recent Posts\n\n?([\s\S]*?)(?=\n## [^R]|\n## $|$)/m);
if (!recentSection || !recentSection[1].trim()) return null;
const sectionContent = recentSection[1];
let pruned = 0;
const lines = sectionContent.split('\n');
const kept = [];
for (const line of lines) {
const dateMatch = line.match(/^- \[(\d{4}-\d{2}-\d{2})\]/);
if (dateMatch) {
if (dateMatch[1] < cutoffStr) {
pruned++;
continue;
}
}
kept.push(line);
}
if (pruned === 0) return null;
const newSection = kept.join('\n');
const content = stateContent.replace(recentSection[1], newSection);
return { content, pruned };
}
/**
* Update follower count and recalculate growth metrics.
* @param {string} stateContent - Full state file content
* @param {{ count: number, month: string }} opts
* @returns {{ content: string, changes: string[] } | null}
*/
export function updateFollowerCount(stateContent, { count, month }) {
let content = stateContent;
const changes = [];
const previousCount = parseInt(extractField(content, 'follower_count') || '0', 10);
const delta = count - previousCount;
// Update follower_count
content = replaceField(content, 'follower_count', String(count));
changes.push(`follower_count → ${count} (${delta >= 0 ? '+' : ''}${delta})`);
// Recalculate growth_rate_needed
const target = parseInt(extractField(content, 'follower_target') || '10000', 10);
const targetDate = extractField(content, 'target_date');
const remaining = target - count;
if (targetDate && targetDate !== 'null' && targetDate !== '""') {
const [tYear, tMonth] = targetDate.split('-').map(Number);
const [mYear, mMonth] = month.split('-').map(Number);
const monthsLeft = (tYear - mYear) * 12 + (tMonth - mMonth);
const effectiveMonths = Math.max(1, monthsLeft);
const rateNeeded = Math.ceil(remaining / effectiveMonths);
content = replaceField(content, 'growth_rate_needed', String(rateNeeded));
changes.push(`growth_rate_needed → ${rateNeeded}/month`);
}
// Append to Milestone Log section
const logEntry = `- [${month}] ${count} (${delta >= 0 ? '+' : ''}${delta})`;
content = content.replace(
/^(## Milestone Log\n)/m,
`$1${logEntry}\n`
);
changes.push(`Milestone Log += ${month}`);
if (content === stateContent) return null;
return { content, changes };
}
/**
* I/O wrapper: read state file, apply update function, write atomically.
* @param {function(string): {content: string}|null} updateFn - Pure update function
*/
export function writeState(updateFn) {
const content = readFileSync(STATE_FILE, 'utf-8');
const result = updateFn(content);
if (!result) {
console.log('No changes needed.');
return;
}
const tmpPath = STATE_FILE + '.tmp';
writeFileSync(tmpPath, result.content, 'utf-8');
renameSync(tmpPath, STATE_FILE);
if (result.changes) {
console.log('State updated:', result.changes.join(', '));
} else if (result.pruned !== undefined) {
console.log(`Pruned ${result.pruned} old entries.`);
}
}
// Standalone mode
if (import.meta.url === `file://${process.argv[1]}`) {
const args = process.argv.slice(2);
if (args.includes('--update-post')) {
const getArg = (flag) => { const i = args.indexOf(flag); return i >= 0 ? args[i + 1] : ''; };
writeState(content => updatePostTracking(content, {
postDate: getArg('--date') || new Date().toISOString().slice(0, 10),
postTopic: getArg('--topic') || 'unknown',
hookText: getArg('--hook') || '',
charCount: parseInt(getArg('--chars') || '0', 10),
format: getArg('--format') || 'post'
}));
} else if (args.includes('--prune')) {
const days = parseInt(args[args.indexOf('--prune') + 1] || '90', 10);
writeState(content => pruneContentHistory(content, days));
} else if (args.includes('--update-followers')) {
const getArg = (flag) => { const i = args.indexOf(flag); return i >= 0 ? args[i + 1] : ''; };
writeState(content => updateFollowerCount(content, {
count: parseInt(getArg('--count') || '0', 10),
month: getArg('--month') || new Date().toISOString().slice(0, 7)
}));
} else {
console.log('Usage:');
console.log(' node state-updater.mjs --update-post --date YYYY-MM-DD --topic "topic" --hook "Hook text" --chars 1500 --format post');
console.log(' node state-updater.mjs --prune [days]');
console.log(' node state-updater.mjs --update-followers --count 920 --month 2026-04');
}
}

View file

@ -0,0 +1,90 @@
#!/usr/bin/env node
// stop-reminder.mjs
// Stop hook for linkedin-studio 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-studio 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-studio.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:calendar', '/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);

View file

@ -0,0 +1,49 @@
// Pure function for week-rollover logic.
// Exported separately for testability.
/**
* Apply week rollover to state file content.
* Returns updated content string if rollover was applied, null otherwise.
*
* @param {string} stateContent - Full state file content (with YAML frontmatter)
* @param {string} currentWeek - Week value from state file (e.g. "2026-W14")
* @param {string} actualWeek - Computed current ISO week (e.g. "2026-W15")
* @returns {{ content: string, message: string } | null}
*/
export function applyWeekRollover(stateContent, currentWeek, actualWeek) {
if (!actualWeek) return null;
// Case 1: current_week is empty — initialize without resetting posts
if (!currentWeek) {
const updated = stateContent.replace(
/^current_week: .*/m,
`current_week: "${actualWeek}"`
);
if (updated === stateContent) return null;
return {
content: updated,
message: `Initialized current_week to ${actualWeek}.`
};
}
// Case 2: week matches — no action needed
if (currentWeek === actualWeek) return null;
// Case 3: week changed — reset posts_this_week and update current_week
let updated = stateContent;
updated = updated.replace(
/^posts_this_week: .*/m,
'posts_this_week: 0'
);
updated = updated.replace(
/^current_week: .*/m,
`current_week: "${actualWeek}"`
);
if (updated === stateContent) return null;
return {
content: updated,
message: `Auto-reset: posts_this_week → 0 for new week ${actualWeek} (was ${currentWeek}).`
};
}