#!/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, readdirSync } 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'; import { getDataRoot } from './data-root.mjs'; import { migrateData } from './migrate-data.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); } // B-S3: newest trend capture (ISO date) from the persistent trend store, read // as raw JSON. The hook must NOT spawn the tsx CLI at session start (slow + // needs node_modules/tsx); the store schema (trends[].capturedAt) is stable and // versioned, so a direct read is safe. Twin of scripts/trends/src/store.ts: // newestCaptureDate (max capturedAt). Returns null when the store is absent, // unreadable, or empty — no captures yet ⇒ no nudge. function trendsNewestCapture(storePath) { if (!existsSync(storePath)) return null; try { const parsed = JSON.parse(readFileSync(storePath, 'utf-8')); const trends = Array.isArray(parsed?.trends) ? parsed.trends : []; let newest = null; for (const t of trends) { const d = typeof t?.capturedAt === 'string' ? t.capturedAt : null; if (d && (newest === null || d > newest)) newest = d; } return newest; } catch { return null; } } // RE-R2b: the most recent morning brief (date + pre-rendered summary) for // session-start surfacing. Reads the dated Markdown directly — NO tsx (same // zero-dep discipline as trendsNewestCapture above): the brief's frontmatter // carries a single-line `summary` the hook surfaces verbatim (extractYaml's // [^"\n]* + .trim() guarantees date/summary are newline-free). Returns null when // the dir is absent / empty / unreadable — no brief yet => no block. function latestMorningBrief(briefDir) { if (!existsSync(briefDir)) return null; try { const files = readdirSync(briefDir) .filter((f) => /^\d{4}-\d{2}-\d{2}\.md$/.test(f)) .sort(); const name = files[files.length - 1]; if (!name) return null; const content = readFileSync(join(briefDir, name), 'utf-8'); return { date: extractYaml(content, 'date'), summary: extractYaml(content, 'summary'), file: join(briefDir, name), }; } catch { return null; } } // SB-S2: brain consolidation freshness. Both read zero-dep through getDataRoot — // the SAME data root the brain CLI's --apply writes to (sidecar reachable by both). // No tsx, no profile.md parse: a readdir count + a tiny JSON read, cost-bounded. function brainLastRun(statePath) { if (!existsSync(statePath)) return null; try { const j = JSON.parse(readFileSync(statePath, 'utf-8')); return typeof j?.last_run === 'string' ? j.last_run : null; } catch { return null; } } function countPublished(publishedDir) { if (!existsSync(publishedDir)) return 0; try { return readdirSync(publishedDir).filter((f) => f.endsWith('.md') && !f.startsWith('.')).length; } catch { return 0; } } 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 = ''; // M0: relocate per-user data to the external root on first run. Idempotent // (silent no-op once .migrated exists); must precede every moved-path read // (voice/profile/scaffold scores, queue). let m0Migration = { status: 'skip', moved: [], copied: [] }; try { m0Migration = migrateData(); } catch { // Non-critical: never block session start on migration failure. } // SB-S2: ensure the brain/ + ingest/ scaffold exists (zero-dep, idempotent). // UNCONDITIONAL — must run on the fresh-install path too (the reminders block // below only runs when the state file exists). Profile.md SEEDING needs the tsx // fold, so it stays the manual `brain init`; here we only mkdir, and remember // whether to nudge (the nudge is appended AFTER the if/else, since the else // branch reassigns `context` and would clobber an early append). let brainProfileMissing = false; try { for (const d of ['brain/journal', 'ingest/inbox', 'ingest/published']) { mkdirSync(join(getDataRoot(''), d), { recursive: true }); } brainProfileMissing = !existsSync(join(getDataRoot('brain'), 'profile.md')); } catch { // Non-critical: never block session start on scaffold-ensure failure. } 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'; } // Trend-store staleness (B-S3): warn-only, ≥7 days. Fires ONLY when the store // already holds captures (newest === null ⇒ no nudge), so a never-scanned user // is never nagged. Neutral wording — "scan for trends" hits trend-spotter's own // trigger; no hardcoded beat (de-niche invariant, §17 guard). const newestTrendCapture = trendsNewestCapture(join(getDataRoot('trends'), 'trends.json')); const daysSinceTrend = daysSince(newestTrendCapture); if (daysSinceTrend !== null && daysSinceTrend >= 7) { reminders += `- Trend signals are ${daysSinceTrend} days old. Scan for trends to refresh the digest before planning content.\\n`; } // Brain consolidation-due (SB-S2): fires only when published gold records exist // and the loop is stale/never-run (a never-used brain never nags). Counts files // + reads the sidecar last_run — no profile.md parse. Zero-dep. const publishedCount = countPublished(join(getDataRoot('ingest'), 'published')); const brainLastRunDate = brainLastRun(join(getDataRoot('brain'), 'consolidation-state.json')); const daysSinceConsolidation = daysSince(brainLastRunDate); if (publishedCount > 0 && (brainLastRunDate === null || (daysSinceConsolidation !== null && daysSinceConsolidation >= 7))) { const since = brainLastRunDate === null ? 'never run' : `${daysSinceConsolidation}d ago`; reminders += `- ${publishedCount} published post(s) captured, last brain consolidation ${since}. Run \`brain consolidate\` to evolve your profile.\\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`; } } // SB-S2: nudge to seed the brain if it isn't initialised (after the if/else, so // the fresh-install branch's `context = …` reassignment can't clobber it). if (brainProfileMissing) { context += '\\n## Brain\\n- Brain not initialised. Run `brain init` to seed your evolving profile.\\n'; } // RE-R2b: surface the latest dated morning brief (zero-tsx; summary is a // pre-rendered single line from the brief's frontmatter). Unconditional on a brief // existing, like the brain nudge above (so the fresh-install branch surfaces it too). const latestBrief = latestMorningBrief(join(getDataRoot('trends'), 'morning-brief')); if (latestBrief && latestBrief.summary) { context += `\\n## Morning Brief (${latestBrief.date})\\n${latestBrief.summary}\\n→ Full brief: ${latestBrief.file}\\n`; } // Read REMEMBER.md for user session context const rememberFile = join(getDataRoot(), 'REMEMBER.md'); const rememberTemplate = join(PLUGIN_ROOT, 'config', 'REMEMBER.template.md'); if (!existsSync(rememberFile) && existsSync(rememberTemplate)) { mkdirSync(dirname(rememberFile), { recursive: true }); 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`; } // M0: log one line when the migration actually relocated data (D5). if (m0Migration.status === 'migrated' && (m0Migration.moved.length || m0Migration.copied.length)) { context += `\\nLinkedIn Studio: migrated ${m0Migration.moved.length} file(s) + ${m0Migration.copied.length} scaffold(s) to the external data root.\\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));