diff --git a/hooks/scripts/__tests__/session-start-brain-consolidation.test.mjs b/hooks/scripts/__tests__/session-start-brain-consolidation.test.mjs new file mode 100644 index 0000000..d07a504 --- /dev/null +++ b/hooks/scripts/__tests__/session-start-brain-consolidation.test.mjs @@ -0,0 +1,80 @@ +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync, copyFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { tmpdir } from 'node:os'; +import { fileURLToPath } from 'node:url'; + +// SB-S2: SessionStart emits a "run brain consolidate" nudge driven by the count of +// published gold records + the consolidation-state.json last_run sidecar (read via +// getDataRoot — the SAME root the brain CLI --apply writes to). Zero-dep, no +// profile.md parse. Subprocess harness with isolated HOME + LINKEDIN_STUDIO_DATA, +// mirroring session-start-trends-staleness.test.mjs. +const here = dirname(fileURLToPath(import.meta.url)); +const hookPath = join(here, '..', 'session-start.mjs'); +const PLUGIN_ROOT = join(here, '..', '..', '..'); +const STATE_TEMPLATE = join(PLUGIN_ROOT, 'config', 'state-file.template.md'); + +const NUDGE = 'brain consolidate'; +const isoDaysAgo = (n) => new Date(Date.now() - n * 86400000).toISOString().slice(0, 10); + +const record = (id) => + `id: ${id}\nprovenance: published\npublished_date: 2026-01-01\ncaptured_at: 2026-01-01\nsource: manual\n---\nbody`; + +function runHook({ withState = true, published = [], lastRun = undefined } = {}) { + const home = mkdtempSync(join(tmpdir(), 'lis-bc-home-')); + const data = mkdtempSync(join(tmpdir(), 'lis-bc-data-')); + try { + if (withState) { + mkdirSync(join(home, '.claude'), { recursive: true }); + copyFileSync(STATE_TEMPLATE, join(home, '.claude', 'linkedin-studio.local.md')); + } + if (published.length) { + mkdirSync(join(data, 'ingest', 'published'), { recursive: true }); + for (const id of published) writeFileSync(join(data, 'ingest', 'published', id + '.md'), record(id)); + } + if (lastRun !== undefined) { + mkdirSync(join(data, 'brain'), { recursive: true }); + writeFileSync(join(data, 'brain', 'consolidation-state.json'), JSON.stringify({ last_run: lastRun })); + } + const stdout = execFileSync('node', [hookPath], { + input: '', + env: { ...process.env, HOME: home, USERPROFILE: home, LINKEDIN_STUDIO_DATA: data }, + encoding: 'utf-8', + }); + return JSON.parse(stdout).hookSpecificOutput.additionalContext; + } finally { + rmSync(home, { recursive: true, force: true }); + rmSync(data, { recursive: true, force: true }); + } +} + +describe('session-start — brain consolidation-due nudge (SB-S2)', () => { + test('fires when published records exist and consolidation never ran', () => { + const ctx = runHook({ published: ['aaaaaaaaaaaa', 'bbbbbbbbbbbb'] }); + assert.ok(ctx.includes(NUDGE), 'nudge expected when published exist + never consolidated'); + assert.ok(ctx.includes('2 published'), 'reports the published count'); + }); + + test('fires when last_run is stale (>=7d)', () => { + const ctx = runHook({ published: ['aaaaaaaaaaaa'], lastRun: isoDaysAgo(10) }); + assert.ok(ctx.includes(NUDGE), 'nudge expected for a 10-day-old last_run'); + }); + + test('silent when last_run is fresh (<7d)', () => { + const ctx = runHook({ published: ['aaaaaaaaaaaa'], lastRun: isoDaysAgo(2) }); + assert.ok(!ctx.includes(NUDGE), 'fresh consolidation => no nudge'); + }); + + test('silent when there are no published records (never nags)', () => { + const ctx = runHook({ published: [] }); + assert.ok(!ctx.includes(NUDGE), 'no published => no nudge'); + }); + + test('fresh install (no state file) emits valid JSON and nudges brain init', () => { + const ctx = runHook({ withState: false }); + assert.equal(typeof ctx, 'string', 'valid JSON additionalContext on fresh install'); + assert.ok(ctx.includes('Brain not initialised'), 'brain-init nudge survives the fresh-install branch'); + }); +}); diff --git a/hooks/scripts/session-start.mjs b/hooks/scripts/session-start.mjs index 04d3be1..c440970 100644 --- a/hooks/scripts/session-start.mjs +++ b/hooks/scripts/session-start.mjs @@ -2,7 +2,7 @@ // 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 { 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'; @@ -51,6 +51,28 @@ function trendsNewestCapture(storePath) { } } +// 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; @@ -77,6 +99,22 @@ try { // 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'); @@ -341,6 +379,17 @@ if (existsSync(STATE_FILE)) { 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') { @@ -448,6 +497,12 @@ if (existsSync(STATE_FILE)) { } } +// 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'; +} + // Read REMEMBER.md for user session context const rememberFile = join(getDataRoot(), 'REMEMBER.md'); const rememberTemplate = join(PLUGIN_ROOT, 'config', 'REMEMBER.template.md');