Step 17 (Wave 4 S4) of the audit remediation. Applies research/03 §D5 + the two S2 residual fixes folded in. No new commands/agents (counts stay 27/19). Newsletter (commands/newsletter.md): new "Distribution channel" section after Step 10 teaching the HONEST native-newsletter mechanics — bypasses organic feed ranking via ONE deduplicated notification per subscriber per edition (NOT a three-touchpoint blast); the mass invite fires once → ~1-2K follower floor (wait until you can spend it); realistic cold-start 0-100 subs months 1-3; discloses non-export / no-canonical / no-read-analytics / per-subscriber decay; explicit below-vs-above-floor decision rule. Sourced to research/03 D5. Profile (commands/profile.md): new "Profile SEO" section — headline as the highest-weight search field + a per-section keyword-target table (headline/about/experience/skills/featured), consistency-over-stuffing rule. Outreach (commands/outreach.md): Step 8c persists the pipeline board to tracked state via the new recordOutreachContact mutation (mirrors Step 16's recordFirstHourPlan): additive last_outreach_date/outreach_active scalars + a non-R-initial ## Outreach Pipeline section in state-updater.mjs + config/state-file.template.md + --record-outreach CLI branch. +7 tests (state-updater 26→33, full hook suite 83→90). Residual 1 (growth-playbook:216): 9:16 "distribution boost" → 4:5/1:1 guidance (9:16 mobile-only opt-in; "immersive distribution" = uncorroborated heuristic). Residual 2 (video-strategy-guide:300): "3-second test determines 70% retention" → "front-load value for muted autoplay" (three-second hook is folklore, not a LinkedIn signal). Verify: grep checks 1-5 pass; test-runner.sh exit 0 (stat-consistency green); state-updater 33/33. [skip-docs] — tre-doc + version bump deferred to Step 21. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
389 lines
16 KiB
JavaScript
389 lines
16 KiB
JavaScript
// 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 };
|
|
}
|
|
|
|
/**
|
|
* Record a first-hour / reply-loop engagement plan deterministically.
|
|
*
|
|
* Additive by contract (older state files predate these fields): a missing
|
|
* scalar is inserted, a missing section is created — existing fields are never
|
|
* touched. Mirrors updatePostTracking: scalar replace + newest-first section
|
|
* append. The section name is deliberately non-`R`-initial so it falls outside
|
|
* pruneContentHistory's `## Recent Posts … (?=\n## [^R])` capture window.
|
|
*
|
|
* @param {string} stateContent - Full state file content
|
|
* @param {{ planDate: string, postTopic?: string, targets?: string[], draftComments?: string[], plan?: string[] }} opts
|
|
* @returns {{ content: string, changes: string[] } | null}
|
|
*/
|
|
export function recordFirstHourPlan(stateContent, { planDate, postTopic = '', targets = [], draftComments = [], plan = [] }) {
|
|
let content = stateContent;
|
|
const changes = [];
|
|
|
|
// 1. last_firsthour_date — replace in place, else insert after last_post_date (additive)
|
|
if (/^last_firsthour_date: .*/m.test(content)) {
|
|
content = replaceField(content, 'last_firsthour_date', `"${planDate}"`);
|
|
} else if (/^last_post_date: .*/m.test(content)) {
|
|
content = content.replace(/^(last_post_date: .*)$/m, `$1\nlast_firsthour_date: "${planDate}"`);
|
|
}
|
|
changes.push(`last_firsthour_date → ${planDate}`);
|
|
|
|
// 2. firsthour_active flag — only touch if the field is declared (additive)
|
|
if (/^firsthour_active: .*/m.test(content)) {
|
|
content = replaceField(content, 'firsthour_active', 'true');
|
|
changes.push('firsthour_active → true');
|
|
}
|
|
|
|
// 3. Build the plan entry block
|
|
const fmtList = (items) => (Array.isArray(items) && items.length ? items.map((i) => `- ${i}`).join('\n') : '- _(none)_');
|
|
const entry = [
|
|
`### [${planDate}] ${postTopic}`.trimEnd(),
|
|
'**Targets:**',
|
|
fmtList(targets),
|
|
'**Draft comments:**',
|
|
fmtList(draftComments),
|
|
'**First-hour timeline:**',
|
|
fmtList(plan),
|
|
''
|
|
].join('\n');
|
|
|
|
// 4. Append to ## First-Hour Plans (newest first); create the section if absent (additive)
|
|
if (/^## First-Hour Plans\b/m.test(content)) {
|
|
content = content.replace(/^(## First-Hour Plans\n\n?)/m, `$1${entry}\n`);
|
|
} else {
|
|
const trimmed = content.replace(/\s*$/, '');
|
|
content = `${trimmed}\n\n## First-Hour Plans\n\n<!-- First-hour / reply-loop plans. Format: ### [YYYY-MM-DD HH:MM] topic -->\n\n${entry}\n`;
|
|
}
|
|
changes.push(`First-Hour Plans += ${planDate} "${postTopic}"`);
|
|
|
|
if (content === stateContent) return null;
|
|
return { content, changes };
|
|
}
|
|
|
|
/**
|
|
* Record an outreach contact / pipeline entry deterministically. Additive: an
|
|
* absent scalar is inserted (never required up front), an absent section is
|
|
* created, and no existing field is touched. Mirrors recordFirstHourPlan:
|
|
* scalar replace + newest-first section append. The section name is
|
|
* deliberately non-`R`-initial ("Outreach …") so it falls outside
|
|
* pruneContentHistory's `## Recent Posts … (?=\n## [^R])` capture window.
|
|
*
|
|
* @param {string} stateContent - Full state file content
|
|
* @param {{ contactDate: string, track?: string, partner?: string, stage?: string, nextAction?: string, dueDate?: string }} opts
|
|
* @returns {{ content: string, changes: string[] } | null}
|
|
*/
|
|
export function recordOutreachContact(stateContent, { contactDate, track = '', partner = '', stage = '', nextAction = '', dueDate = '' }) {
|
|
let content = stateContent;
|
|
const changes = [];
|
|
|
|
// 1. last_outreach_date — replace in place, else insert after last_firsthour_date
|
|
// if present, else after last_post_date (additive — never required up front)
|
|
if (/^last_outreach_date: .*/m.test(content)) {
|
|
content = replaceField(content, 'last_outreach_date', `"${contactDate}"`);
|
|
} else if (/^last_firsthour_date: .*/m.test(content)) {
|
|
content = content.replace(/^(last_firsthour_date: .*)$/m, `$1\nlast_outreach_date: "${contactDate}"`);
|
|
} else if (/^last_post_date: .*/m.test(content)) {
|
|
content = content.replace(/^(last_post_date: .*)$/m, `$1\nlast_outreach_date: "${contactDate}"`);
|
|
}
|
|
changes.push(`last_outreach_date → ${contactDate}`);
|
|
|
|
// 2. outreach_active flag — only touch if the field is declared (additive)
|
|
if (/^outreach_active: .*/m.test(content)) {
|
|
content = replaceField(content, 'outreach_active', 'true');
|
|
changes.push('outreach_active → true');
|
|
}
|
|
|
|
// 3. Build the pipeline entry block
|
|
const fmt = (v) => (v && String(v).trim() ? String(v).trim() : '_(none)_');
|
|
const heading = `### [${contactDate}] ${partner || '(contact)'}${track ? ` — ${track}` : ''}`.trimEnd();
|
|
const entry = [
|
|
heading,
|
|
`- **Stage:** ${fmt(stage)}`,
|
|
`- **Next action:** ${fmt(nextAction)}`,
|
|
`- **Due:** ${fmt(dueDate)}`,
|
|
''
|
|
].join('\n');
|
|
|
|
// 4. Append to ## Outreach Pipeline (newest first); create the section if absent (additive)
|
|
if (/^## Outreach Pipeline\b/m.test(content)) {
|
|
content = content.replace(/^(## Outreach Pipeline\n\n?)/m, `$1${entry}\n`);
|
|
} else {
|
|
const trimmed = content.replace(/\s*$/, '');
|
|
content = `${trimmed}\n\n## Outreach Pipeline\n\n<!-- Outreach contacts / pipeline rows, newest first. Written by /linkedin:outreach. -->\n<!-- Format: ### [YYYY-MM-DD HH:MM] partner — track -->\n\n${entry}\n`;
|
|
}
|
|
changes.push(`Outreach Pipeline += ${contactDate} "${partner || '(contact)'}"`);
|
|
|
|
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 if (args.includes('--record-firsthour')) {
|
|
const getArg = (flag) => { const i = args.indexOf(flag); return i >= 0 ? args[i + 1] : ''; };
|
|
const splitList = (s) => (s ? s.split(';').map(x => x.trim()).filter(Boolean) : []);
|
|
writeState(content => recordFirstHourPlan(content, {
|
|
planDate: getArg('--date') || new Date().toISOString().slice(0, 16).replace('T', ' '),
|
|
postTopic: getArg('--topic') || '',
|
|
targets: splitList(getArg('--targets')),
|
|
draftComments: splitList(getArg('--comments')),
|
|
plan: splitList(getArg('--plan'))
|
|
}));
|
|
} else if (args.includes('--record-outreach')) {
|
|
const getArg = (flag) => { const i = args.indexOf(flag); return i >= 0 ? args[i + 1] : ''; };
|
|
writeState(content => recordOutreachContact(content, {
|
|
contactDate: getArg('--date') || new Date().toISOString().slice(0, 16).replace('T', ' '),
|
|
track: getArg('--track') || '',
|
|
partner: getArg('--partner') || '',
|
|
stage: getArg('--stage') || '',
|
|
nextAction: getArg('--next') || '',
|
|
dueDate: getArg('--due') || ''
|
|
}));
|
|
} 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');
|
|
console.log(' node state-updater.mjs --record-firsthour --date "YYYY-MM-DD HH:MM" --topic "topic" --targets "a;b" --comments "c;d" --plan "e;f"');
|
|
console.log(' node state-updater.mjs --record-outreach --date "YYYY-MM-DD HH:MM" --track collab --partner "@name" --stage pitched --next "follow up" --due YYYY-MM-DD');
|
|
}
|
|
}
|