linkedin-studio/hooks/scripts/state-updater.mjs
Kjell Tore Guttormsen b45fdad911 feat(linkedin-studio): N15 — do-next-kontrakt (recordDoNext + skrivere/lesere + forrige-utgave-kalibrering) [skip-docs]
Måling som ikke endrer neste utgave er teater: report/analyze/ab-test/48h-monitor
endte alle i chat (grep do.next = 0 i hele repoet). N15 gjør kanalen til en kontrakt.

- recordDoNext i hooks/scripts/state-updater.mjs (TDD, 17 nye tester): seksjonen
  Do-Next Directives, rader "[dato] (kilde) direktiv — evidence: peker".
  Levetid = erstatt-per-kilde + 60-dagers aldersgulv, maks 3 per skriv, additiv
  scalar last_donext_date, $-sikre replacement-funksjoner. CLI: --record-do-next.
- Skrivere: report Step 7b, analyze Step 6b, ab-test 2c.6 (kun Adopt),
  post-feedback-monitor (kun 48h-sjekken); analytics-interpreter emitterer nå
  anbefalinger på do-next-form (imperativ + evidens).
- Lesere: post/quick/batch/create Step 0 + newsletter Step 1, som i tillegg
  kalibrerer på forrige utgaves MÅLTE tall via queue-id-match (queue.json →
  analytics/posts) med eksplisitt honest-miss — aldri estimert.
- Guards: test-runner Section 16v (15 ubetingede sjekker, floor 198 → 213),
  $-safety-batteriet dekker recordDoNext (coverage-gate fanget den nye eksporten).

Suiter: test-runner 232/0, hooks 191/0, trends 300/0, brain 134/0, editions 72/0,
specifics-bank 45/0, contract-gate 33/0, tests 35/0, render 60/0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QxvWAjte7vPcF79QeSRvRJ
2026-07-25 15:23:35 +02:00

536 lines
26 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) {
// 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]
* @param {Date} [today=new Date()] - Injectable clock (tests pass a fixed date)
* @returns {{ content: string, pruned: number } | null}
*/
export function pruneContentHistory(stateContent, maxAgeDays = 90, 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;
// No /m flag: with /m the $ alternative in the lookahead matched the end of
// EVERY line, so the lazy capture stopped after the FIRST entry line and
// older entries below it were never scanned (and never pruned). Without /m,
// $ matches only end-of-string and the capture spans the whole section.
const recentSection = stateContent.match(/## Recent Posts\n\n?([\s\S]*?)(?=\n## [^R]|\n## $|$)/);
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<!-- 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).
// 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<!-- 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 };
}
/**
* Record 2-3 "do next" directives deterministically — the measurement→creation
* contract. A recommendation that only ever lands in chat dies there; a directive
* written HERE is read by the create surfaces at their Step 0, so the next post is
* actually shaped by the last report.
*
* Lifetime is REPLACE-BY-SOURCE plus an age floor, not append-forever:
* - each writer (report / analyze / ab-test / 48h-monitor) owns its own slot, so a
* new report supersedes the previous report's directives but never evicts an
* A/B verdict that landed in between;
* - rows older than `maxAgeDays` are dropped on every write regardless of source,
* so a writer that goes quiet cannot leave stale directives steering drafts
* forever (pruneContentHistory's bounded-section pattern);
* - at most 3 rows per write — this is a steering signal, not a backlog.
*
* Additive by contract, like recordFirstHourPlan/recordOutreachContact: a missing
* scalar is inserted, a missing section created, no existing field touched. The
* section name is deliberately non-`R`-initial ("Do-Next …") so it falls outside
* pruneContentHistory's `## Recent Posts … (?=\n## [^R])` capture window.
*
* @param {string} stateContent - Full state file content
* @param {{ recordDate: string, source?: string, directives?: Array<string|{directive: string, evidence?: string}>, maxAgeDays?: number, today?: Date }} opts
* @returns {{ content: string, changes: string[] } | null}
*/
export function recordDoNext(stateContent, { recordDate, source = 'report', directives = [], maxAgeDays = 60, today = new Date() }) {
let content = stateContent;
const changes = [];
const MAX_PER_WRITE = 3;
const cutoff = new Date(today);
cutoff.setDate(cutoff.getDate() - maxAgeDays);
const cutoffStr = cutoff.toISOString().slice(0, 10);
// 1. Build this write's rows (capped; string and {directive, evidence} both accepted)
const rows = directives.slice(0, MAX_PER_WRITE).map((d) => {
const text = (typeof d === 'string' ? d : d && d.directive) || '';
const evidence = (typeof d === 'string' ? '' : (d && d.evidence) || '').trim();
return `- [${recordDate}] (${source}) ${String(text).trim()} — evidence: ${evidence || '_(none)_'}`;
}).filter((r) => !/\) +— evidence:/.test(r)); // drop rows with an empty directive
// 2. Rebuild the existing section body: drop this source's rows (superseded) and
// every row past the age floor (any source). Non-row lines (format comments,
// blank lines) are kept verbatim.
// No /m flag — same trap pruneContentHistory documents: with /m the `$` alternative
// in the lookahead matches the end of EVERY line, so the lazy body capture stops
// after the first line and older rows below it are never scanned (never superseded,
// never pruned). Without /m, `$` is end-of-string and the capture spans the section.
const sectionRe = /(## Do-Next Directives\n\n?)([\s\S]*?)(?=\n## |$)/;
const existing = content.match(sectionRe);
let dropped = 0;
if (existing) {
// The section's leading `<!-- … -->` format comments are documentation, not data:
// they stay directly under the heading, above the rows (a row prepended above them
// would push the format contract to the bottom of the section every write).
const lines = existing[2].split('\n');
let cut = 0;
while (cut < lines.length && !/^- \[/.test(lines[cut]) && (lines[cut].trim() === '' || lines[cut].trim().startsWith('<!--'))) cut++;
const preamble = lines.slice(0, cut).join('\n').replace(/\s+$/, '');
const kept = lines.slice(cut).filter((line) => {
if (line.trim() === '') return false; // rebuilt below, never accumulated
const row = line.match(/^- \[(\d{4}-\d{2}-\d{2})[^\]]*\] \(([^)]*)\)/);
if (!row) return true; // unexpected user text: keep verbatim
if (row[2] === source) { dropped++; return false; } // superseded by this write
if (row[1] < cutoffStr) { dropped++; return false; } // past the age floor
return true;
});
// Newest first: this write's rows go directly under the heading + comments.
const parts = [];
if (preamble) parts.push(preamble, '');
parts.push(...rows, ...kept);
const body = `${parts.join('\n')}\n`;
// Replacement FUNCTION, not string: rows embed untrusted directive/evidence text —
// `$&`/`$1`/`$$` must be inserted verbatim (same class as the other section writes).
content = content.replace(sectionRe, () => `${existing[1]}${body}`);
} else if (rows.length) {
const trimmed = content.replace(/\s*$/, '');
const header = '## Do-Next Directives\n\n<!-- Measurement -> next action. Written by /linkedin:report, /linkedin:analyze, /linkedin:ab-test (Adopt) and the 48h post-feedback-monitor; read by the create surfaces at Step 0. -->\n<!-- Format: - [YYYY-MM-DD] (source) directive - evidence: pointer -->\n\n';
content = `${trimmed}\n\n${header}${rows.join('\n')}\n`;
}
if (rows.length === 0 && dropped === 0) return null;
// 3. last_donext_date — only when something was actually recorded. Replace in place,
// else insert after the nearest existing date anchor (additive). Report the change
// only inside the branch that writes it: with no anchor the scalar is not inserted.
if (rows.length) {
if (/^last_donext_date: .*/m.test(content)) {
content = replaceField(content, 'last_donext_date', `"${recordDate}"`);
changes.push(`last_donext_date → ${recordDate}`);
} else {
const anchor = ['last_outreach_date', 'last_firsthour_date', 'last_post_date'].find((f) => new RegExp(`^${f}: .*`, 'm').test(content));
if (anchor) {
content = content.replace(new RegExp(`^(${anchor}: .*)$`, 'm'), (m) => `${m}\nlast_donext_date: "${recordDate}"`); // function, not string: keeps recordDate `$`-safe by construction
changes.push(`last_donext_date → ${recordDate}`);
}
}
changes.push(`Do-Next Directives ← ${rows.length} from ${source} (${recordDate})`);
}
if (dropped) changes.push(`Do-Next Directives: ${dropped} superseded/expired row(s) dropped`);
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 if (args.includes('--record-do-next')) {
const getArg = (flag) => { const i = args.indexOf(flag); return i >= 0 ? args[i + 1] : ''; };
// Rows are `;`-separated; each row is `directive :: evidence` (evidence optional).
const parseDirectives = (s) => (s ? s.split(';').map((row) => {
const [directive, ...rest] = row.split('::');
return { directive: (directive || '').trim(), evidence: rest.join('::').trim() };
}).filter((d) => d.directive) : []);
writeState(content => recordDoNext(content, {
recordDate: getArg('--date') || new Date().toISOString().slice(0, 10),
source: getArg('--source') || 'report',
directives: parseDirectives(getArg('--directives')),
maxAgeDays: parseInt(getArg('--max-age') || '60', 10)
}));
} 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');
console.log(' node state-updater.mjs --record-do-next --date YYYY-MM-DD --source report --directives "do X :: evidence;do Y :: evidence" [--max-age 60]');
}
}