feat(linkedin-studio): SB-S2 session-start scaffold-ensure + consolidation nudge [skip-docs]
session-start.mjs (zero-dep, Edit): unconditional brain/+ingest/ scaffold-ensure (mkdir, runs on the fresh-install path too); a consolidation-due nudge in the reminders block (counts published records + reads consolidation-state.json last_run via getDataRoot — same root the CLI --apply writes; no profile.md parse, \n idiom, null-safe never-nags); a brain-init nudge appended AFTER the if/else so the fresh-install branch's context reassignment can't clobber it. + readdirSync import. SC6 hook test (5 cases) via heredoc. Hook suite 131→136/0; no recompile (body-only edit). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RigJBiRFNtFZKCz21qNbQ4
This commit is contained in:
parent
88356b8a83
commit
2d01dfca52
2 changed files with 136 additions and 1 deletions
|
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
// SessionStart hook for linkedin-studio plugin
|
// SessionStart hook for linkedin-studio plugin
|
||||||
// Reads persistent state and session context, outputs JSON with additionalContext
|
// 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 { join, dirname } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { calculateScore } from './personalization-score.mjs';
|
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() {
|
function isoWeek() {
|
||||||
const d = new Date();
|
const d = new Date();
|
||||||
const dayNum = d.getUTCDay() || 7;
|
const dayNum = d.getUTCDay() || 7;
|
||||||
|
|
@ -77,6 +99,22 @@ try {
|
||||||
// Non-critical: never block session start on migration failure.
|
// 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)) {
|
if (existsSync(STATE_FILE)) {
|
||||||
const stateContent = readFileSync(STATE_FILE, 'utf-8');
|
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`;
|
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
|
// Milestone reminders
|
||||||
if (milestonePhase && followerCount > 0) {
|
if (milestonePhase && followerCount > 0) {
|
||||||
if (milestoneStatus === 'SIGNIFICANTLY BEHIND') {
|
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
|
// Read REMEMBER.md for user session context
|
||||||
const rememberFile = join(getDataRoot(), 'REMEMBER.md');
|
const rememberFile = join(getDataRoot(), 'REMEMBER.md');
|
||||||
const rememberTemplate = join(PLUGIN_ROOT, 'config', 'REMEMBER.template.md');
|
const rememberTemplate = join(PLUGIN_ROOT, 'config', 'REMEMBER.template.md');
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue