// 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) { // Replacement FUNCTION, not string: most call sites pass dates/integers/booleans, // but `:58` passes the untrusted `last_post_topic`. In a replacement *string*, // `$&`/`` $` ``/`$'`/`$$` (and `$n` group refs) are special, so a `$`-bearing topic // (e.g. "$& budget") would expand `$&` to the whole matched line and silently // corrupt the scalar. A function inserts `value` verbatim — closing the last member // of the `$`-injection class the S12 section-append fix targeted. 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}`; // Replacement FUNCTION, not string: `entry` embeds untrusted user content // (hookPreview, postTopic). In a replacement *string*, `$1`/`$&`/`` $` ``/`$'`/`$$` // are special, so a `$`-bearing topic (e.g. "$100 budget cut") would re-inject the // captured heading and drop characters, silently corrupting state. A function // inserts `entry` verbatim. (m === the whole captured heading.) content = content.replace( /^(## Recent Posts\n\n?)/m, (m) => `${m}${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'); // Replacement FUNCTION, not string: `newSection` is rebuilt from KEPT user // entries; with a string search, `$&`/`` $` ``/`$'`/`$$` in any kept post would be // interpreted and corrupt the rewrite. A function inserts it verbatim. 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})`; // Replacement FUNCTION, not string: same class as the other section appends. // `logEntry` is month + integers today (no `$`), but a function keeps the whole // append family uniform and `$`-safe by construction. content = content.replace( /^(## Milestone Log\n)/m, (m) => `${m}${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). // Report the change only inside the branch that actually writes it: if neither // anchor field exists, the scalar is not inserted and must not be reported as changed. if (/^last_firsthour_date: .*/m.test(content)) { content = replaceField(content, 'last_firsthour_date', `"${planDate}"`); changes.push(`last_firsthour_date → ${planDate}`); } else if (/^last_post_date: .*/m.test(content)) { content = content.replace(/^(last_post_date: .*)$/m, (m) => `${m}\nlast_firsthour_date: "${planDate}"`); // function, not string: `m` === the matched line (was `$1`); keeps planDate `$`-safe by construction 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, (m) => `${m}${entry}\n`); // function, not string: entry embeds untrusted topic/targets/comments — `$`-safe } else { const trimmed = content.replace(/\s*$/, ''); content = `${trimmed}\n\n## First-Hour Plans\n\n\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). // Report the change only inside the branch that actually writes it. if (/^last_outreach_date: .*/m.test(content)) { content = replaceField(content, 'last_outreach_date', `"${contactDate}"`); changes.push(`last_outreach_date → ${contactDate}`); } else if (/^last_firsthour_date: .*/m.test(content)) { content = content.replace(/^(last_firsthour_date: .*)$/m, (m) => `${m}\nlast_outreach_date: "${contactDate}"`); // function, not string: `m` === the matched line (was `$1`); keeps contactDate `$`-safe by construction changes.push(`last_outreach_date → ${contactDate}`); } else if (/^last_post_date: .*/m.test(content)) { content = content.replace(/^(last_post_date: .*)$/m, (m) => `${m}\nlast_outreach_date: "${contactDate}"`); // function, not string: `m` === the matched line (was `$1`); keeps contactDate `$`-safe by construction 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, (m) => `${m}${entry}\n`); // function, not string: entry embeds untrusted partner/stage/nextAction — `$`-safe } else { const trimmed = content.replace(/\s*$/, ''); content = `${trimmed}\n\n## Outreach Pipeline\n\n\n\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'); } }