diff --git a/.gitignore b/.gitignore index 53ec54a..1fa6d8f 100644 --- a/.gitignore +++ b/.gitignore @@ -41,7 +41,6 @@ assets/drafts/week-*/ assets/analytics/exports/ assets/analytics/posts/ assets/analytics/weekly-reports/ -assets/analytics/content-history.md # Internal development files (not for public release) BACKLOG.md diff --git a/config/content-history.template.md b/config/content-history.template.md deleted file mode 100644 index bc836ef..0000000 --- a/config/content-history.template.md +++ /dev/null @@ -1,18 +0,0 @@ -# Content History Log - -Tracks all LinkedIn posts created through the plugin. Append-only — never edit existing entries. - -Auto-initialized from `config/content-history.template.md` when the first post is logged. - -## Format - -| Date | Hook | Topic | Format | Words | Chars | Source | -|------|------|-------|--------|-------|-------|--------| - - - - -## Content Log - -| Date | Hook | Topic | Format | Words | Chars | Source | -|------|------|-------|--------|-------|-------|--------| diff --git a/docs/second-brain/architecture.md b/docs/second-brain/architecture.md index b3b9e49..67adb0a 100644 --- a/docs/second-brain/architecture.md +++ b/docs/second-brain/architecture.md @@ -77,7 +77,7 @@ Run on a cadence (reuse the existing session-start staleness-nudge surface) as a | **SB-S0 — Foundation** ✅ *landed 2026-06-23* | `brain/` scaffold + two-layer `profile.md` (fold in `user-profile.md`) + `index.md` MOC + `operations.md` + `journal/`; entity-id + provenance shape as a small typed, tested module; `ingest/` dirs + manual-import contract. **No loop yet.** Shipped as `scripts/brain/` (TS, 34 tests, gate-wired BRAIN floor); fold = P1 labeled-scalars + P2 expertise (checkbox-prefs deferred, §8 of `plan-sb-s0.md`). | Smallest thing that stands up and is testable; locks the id/provenance spine everything hangs on | | **SB-S1 — Ingest + gold signal** ✅ *landed 2026-06-23* | Manual import → `ingest/published/` with `provenance=published` (CLI `brain ingest`); voice-trainer wired to learn from published-only, never `ai-draft`, gate-enforced. Shipped as `scripts/brain/src/ingest.ts` + `docs/second-brain/ingest-manual-import.md` (v0.5.1). **No profile.md mutation** (SB-S2). | The gold signal before the loop that consumes it | | **SB-S2 — Evolution loop** ✅ *landed 2026-06-23* | Operator-invoked, operator-gated consolidation: `brain consolidate` (`--gather`/`--propose`/`--apply --confirm`) → profile **diff** w/ evidence_count/provenance/timestamps; threshold-promotion (N=3); contradiction → keep-both with distinct ids (no supersede); decay-flag (90d); `consolidation-state.json` sidecar; zero-dep session-start consolidation-due nudge + scaffold-ensure. Shipped as `scripts/brain/src/consolidate.ts` + `docs/second-brain/consolidation-loop.md` (v0.5.2). **Operator decisions:** journal deferred · no new agent (session extracts) · motor-only (no reader until S3). | The compounding mechanism | -| **SB-S3 — Cross-silo graph + ops centre** | Thread the id through tributaries (post↔specific↔trend↔analytics assemblable); flesh out `operations.md`; retire the dead `content-history.md` + triple-post reconciliation. **S3a ✅ first reader · S3b ✅ supersede · S3c ✅ cross-silo id-threading** (hub-side: the published record carries the `specifics`/`trends` ids it was built from + a pure analytics resolver — `scripts/brain/src/assemble.ts`, `brain assemble`; tributaries untouched). **S3d ✅ ops centre** (`operations.md` is a read tributary; `strategy-advisor` honours the dated "who I am now" anchor that *deprecates older inferences* — advisory/reader-side). **S3e remains** (dead `content-history.md` retirement + triple-post post-tracking↔published↔analytics reconciliation — the loose silo S3c kept out). | Finally answers "which raw material performs?" | +| **SB-S3 — Cross-silo graph + ops centre** | Thread the id through tributaries (post↔specific↔trend↔analytics assemblable); flesh out `operations.md`; retire the dead `content-history.md` + triple-post reconciliation. **S3a ✅ first reader · S3b ✅ supersede · S3c ✅ cross-silo id-threading** (hub-side: the published record carries the `specifics`/`trends` ids it was built from + a pure analytics resolver — `scripts/brain/src/assemble.ts`, `brain assemble`; tributaries untouched). **S3d ✅ ops centre** (`operations.md` is a read tributary; `strategy-advisor` honours the dated "who I am now" anchor that *deprecates older inferences* — advisory/reader-side). **S3e ✅ hygiene + reconciliation** (dead `content-history.md` retired across its 8 plumbing surfaces; `brain reconcile` reconciles silo 1 `## Recent Posts` ↔ the silo 2↔3 graph read-side, surfacing the coverage gap — created posts never `brain ingest`-ed; auto-capture of specifics/trends = a flagged follow-up). **The second-brain arc is complete** (S4 EØS DMA-connector optional). | Finally answers "which raw material performs?" | | *(later / optional)* **SB-S4** | EU/EEA DMA portability API as an auto-tributary into `ingest/inbox/` | Additive; never a dependency | ## What's genuinely hard (honest flags) diff --git a/hooks/prompts/state-update-reminder.md b/hooks/prompts/state-update-reminder.md index eaa7f54..cb8c529 100644 --- a/hooks/prompts/state-update-reminder.md +++ b/hooks/prompts/state-update-reminder.md @@ -63,23 +63,3 @@ If a LinkedIn post was created or finalized in this session, save the full post - **Ask the user for confirmation** before writing: "I'll save this post as a voice sample for drift detection. OK?" - This builds the voice sample library that enables automatic drift scoring (needs 5+ samples for reliable scoring) - The more samples collected, the more accurate the voice-trainer's drift detection becomes - -**5. Content History Log** (if a post was created) - -If a LinkedIn post was created or finalized, append an entry to the content history log: - -- If `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/analytics/content-history.md` does not exist, initialize it from `config/content-history.template.md` -- Append a new row to the "## Content Log" table: - ``` - | YYYY-MM-DD | "Hook text..." | topic_area | format | word_count | char_count | source | - ``` - Where: - - `date`: Today's date - - `hook`: First 60 characters of the hook line - - `topic`: Matching expertise_area value (for pillar tracking) - - `format`: post/quick/react/video/pipeline - - `word_count`: Word count of the full post - - `char_count`: Character count of the full post - - `source`: original/url/curated (where the idea came from) -- This is append-only — never edit or delete existing entries -- This log enables `/linkedin:report` and `analytics-interpreter` to track content production over time without requiring LinkedIn CSV imports diff --git a/hooks/scripts/__tests__/migrate-data.test.mjs b/hooks/scripts/__tests__/migrate-data.test.mjs index 036a32c..01e9c4e 100644 --- a/hooks/scripts/__tests__/migrate-data.test.mjs +++ b/hooks/scripts/__tests__/migrate-data.test.mjs @@ -34,7 +34,6 @@ describe('migrateData', () => { if (withRuntime) { writeDeep(join(pluginRoot, 'assets/voice-samples/authentic-voice-samples.local.md'), 'REAL VOICE 227 lines'); writeDeep(join(pluginRoot, 'assets/drafts/queue.json'), '{"version":1,"queue":[]}'); - writeDeep(join(pluginRoot, 'assets/analytics/content-history.md'), '# history'); writeDeep(join(pluginRoot, 'assets/analytics/exports/content-2026-W22.csv'), 'a,b\n1,2\n'); writeDeep(join(pluginRoot, 'assets/analytics/posts/2026-05-26.json'), '{"x":1}'); writeDeep(join(pluginRoot, 'assets/analytics/weekly-reports/2026-W22.json'), '{"w":22}'); @@ -47,18 +46,16 @@ describe('migrateData', () => { } } - test('(a) gitignored runtime files MOVED: source gone, dest byte-equal, voice drops .local, content-history moved', () => { + test('(a) gitignored runtime files MOVED: source gone, dest byte-equal, voice drops .local', () => { setup({ withRuntime: true }); migrateData({ pluginRoot }); assert.ok(!existsSync(join(pluginRoot, 'assets/voice-samples/authentic-voice-samples.local.md')), 'voice source gone'); assert.ok(!existsSync(join(pluginRoot, 'assets/drafts/queue.json')), 'queue source gone'); - assert.ok(!existsSync(join(pluginRoot, 'assets/analytics/content-history.md')), 'content-history source gone'); assert.ok(!existsSync(join(pluginRoot, 'assets/analytics/exports/content-2026-W22.csv')), 'export source gone'); assert.equal(readFileSync(join(dataRoot, 'voice-samples/authentic-voice-samples.md'), 'utf-8'), 'REAL VOICE 227 lines'); assert.equal(readFileSync(join(dataRoot, 'drafts/queue.json'), 'utf-8'), '{"version":1,"queue":[]}'); - assert.ok(existsSync(join(dataRoot, 'analytics/content-history.md'))); assert.ok(existsSync(join(dataRoot, 'analytics/exports/content-2026-W22.csv'))); assert.ok(existsSync(join(dataRoot, 'analytics/posts/2026-05-26.json'))); assert.ok(existsSync(join(dataRoot, 'analytics/weekly-reports/2026-W22.json'))); diff --git a/hooks/scripts/migrate-data.mjs b/hooks/scripts/migrate-data.mjs index 2116c9c..3d05c01 100644 --- a/hooks/scripts/migrate-data.mjs +++ b/hooks/scripts/migrate-data.mjs @@ -30,7 +30,6 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); const MOVE_FILES = [ ['assets/voice-samples/authentic-voice-samples.local.md', 'voice-samples/authentic-voice-samples.md'], // D6: drop .local ['assets/drafts/queue.json', 'drafts/queue.json'], - ['assets/analytics/content-history.md', 'analytics/content-history.md'], // B1 ['config/user-profile.local.md', 'profile/user-profile.md'], // D1 (expected-absent today) ]; diff --git a/hooks/scripts/session-start.mjs b/hooks/scripts/session-start.mjs index c440970..3499273 100644 --- a/hooks/scripts/session-start.mjs +++ b/hooks/scripts/session-start.mjs @@ -91,7 +91,7 @@ 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, content-history). +// (voice/profile/scaffold scores, queue). let m0Migration = { status: 'skip', moved: [], copied: [] }; try { m0Migration = migrateData(); diff --git a/references/data-path-convention.md b/references/data-path-convention.md index 19ee3dd..c77c678 100644 --- a/references/data-path-convention.md +++ b/references/data-path-convention.md @@ -23,7 +23,7 @@ version. This generalizes the proven newsletter pattern ``` ${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/ voice-samples/ authentic-voice-samples.md chronicle-voice-drift-log.md - analytics/ exports/ posts/ weekly-reports/ monthly-reports/ ab-tests/ content-history.md + analytics/ exports/ posts/ weekly-reports/ monthly-reports/ ab-tests/ drafts/ queue.json week-*/ carousel/ multiplatform/ repurposed/ profile/ user-profile.md frameworks/ .md diff --git a/scripts/brain/src/cli.ts b/scripts/brain/src/cli.ts index 9901331..2e121dd 100644 --- a/scripts/brain/src/cli.ts +++ b/scripts/brain/src/cli.ts @@ -26,6 +26,7 @@ import { type ProfileDiff, } from "./consolidate.js"; import { assemblePostGraph, loadAnalyticsRows } from "./assemble.js"; +import { loadRecentPosts, reconcileRecentPosts, summarizeReconcile } from "./reconcile.js"; import { dataRoot } from "./dataRoot.js"; import { ingestText, listPublished, parsePublishedRecord, scanInbox } from "./ingest.js"; import { parseProfile, serializeProfile } from "./profile.js"; @@ -91,7 +92,8 @@ function usage(msg: string): never { " ingest --file [--source ] [--date ] [--specific ]… [--trend ]…\n" + " ingest --scan-inbox [--source ]\n" + " published list [--json]\n" + - " assemble", + " assemble\n" + + " reconcile", ); process.exit(2); } @@ -200,6 +202,52 @@ function runAssemble(_flags: Record): void { } } +/** + * SB-S3e: read-only triple-post reconciliation — silo 1 (`## Recent Posts`, + * auto-tracked creation) vs the silo 2↔3 graph. Surfaces the coverage gap + * (created posts never `brain ingest`-ed). Writes NOTHING. Loads real + * `PublishedRecord[]` (with `body`) via the same inline parse as `runAssemble` + * — NOT `listPublished` (body-less). Calls the core by its literal name. + */ +function runReconcile(_flags: Record): void { + const pubDir = dataRoot(join("ingest", "published")); + const records = existsSync(pubDir) + ? readdirSync(pubDir) + .filter((f) => f.endsWith(".md") && !f.startsWith(".")) + .map((f) => { + try { + return parsePublishedRecord(readFileSync(join(pubDir, f), "utf8")); + } catch { + return null; + } + }) + .filter((r): r is NonNullable => r !== null) + : []; + const analytics = loadAnalyticsRows(); + const graph = assemblePostGraph({ records, analytics }); + const recentPosts = loadRecentPosts(); + const nodes = reconcileRecentPosts({ recentPosts, records, graph }); + const sum = summarizeReconcile(nodes); + if (nodes.length === 0) { + console.log( + "No tracked posts in `## Recent Posts` to reconcile (check that posts were created via the plugin, or that STATE_FILE points at your state file).", + ); + return; + } + console.log( + `Reconcile — ${sum.created} created · ${sum.inGraph} in-graph · ${sum.inBrainOnly} in-brain-only · ${sum.orphaned} orphaned:`, + ); + for (const n of nodes) { + const id = n.contentId ? ` · ${n.contentId}` : ""; + console.log(`\n· ${n.recentPost.date} · ${n.tier}${id}\n "${n.recentPost.hook}"`); + } + if (sum.orphaned > 0) { + console.log( + `\n${sum.orphaned} created post(s) are not in the brain graph — \`brain ingest --file

[--specific ] [--trend ]\` to feed them.`, + ); + } +} + function renderDiffMd(diff: ProfileDiff): string { const lines = ["# Pending profile diff", "", "> Operator-gated. Review, then `brain consolidate --apply --diff brain/pending-diff.json --confirm`.", ""]; const section = (title: string, items: string[]) => { @@ -310,6 +358,7 @@ function main(): void { if (command === "published") return runPublished(rest, flags); if (command === "consolidate") return runConsolidate(flags); if (command === "assemble") return runAssemble(flags); + if (command === "reconcile") return runReconcile(flags); usage(command ? `unknown command: ${command}` : "no command given"); } diff --git a/scripts/brain/src/reconcile.ts b/scripts/brain/src/reconcile.ts new file mode 100644 index 0000000..08115be --- /dev/null +++ b/scripts/brain/src/reconcile.ts @@ -0,0 +1,164 @@ +/** + * SB-S3e — triple-post reconciliation (the LAST S3 slice, read-side). + * + * Silo 1 (`## Recent Posts` in the runtime state file) is the AUTO-tracked stream + * of posts created via the plugin (written by `updatePostTracking`, + * state-updater.mjs:116). Silo 2 (`ingest/published/.md`) + silo 3 (analytics) + * are joined by SB-S3c's `assemblePostGraph`, but ONLY for posts the user MANUALLY + * ran `brain ingest` on. So the graph is blind to everything created-but-never- + * ingested. This module reconciles silo 1 against the graph and surfaces that + * COVERAGE GAP — read-only, never writing the state silo. + * + * Honest limit: silo 1 carries only a ≤60-char (possibly `…`-truncated) hook + * preview, a weaker signal than silo 2's full body; below a floor it cannot + * discriminate → `orphaned-in-state`. Read-side cannot reconstruct the + * specifics/trends a post was built from — that needs auto-capture (a follow-up). + * + * PURE core: `parseRecentPosts` / `reconcileRecentPosts` / `summarizeReconcile` + * take strings/objects and return data — no FS/clock/network. The only IO is + * `loadRecentPosts`, which reads the state file via the canonical `getStateFile()` + * precedence (`STATE_FILE` first) — a DIFFERENT root than the brain dataRoot. + */ + +import { existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +import { normalize, type PostGraphNode } from "./assemble.js"; +import type { PublishedRecord } from "./ingest.js"; + +export interface RecentPost { + date: string; + hook: string; + charCount: number; + topic: string; +} + +export type ReconcileTier = "in-graph" | "in-brain-only" | "orphaned-in-state"; + +export interface ReconcileNode { + recentPost: RecentPost; + tier: ReconcileTier; + /** The matched published record's id, when a record matched. */ + contentId?: string; +} + +export interface ReconcileSummary { + created: number; + inGraph: number; + inBrainOnly: number; + orphaned: number; +} + +/** + * Minimum normalized-hook length to attempt a prefix match. Mirrors + * `assemble.ts`'s `PREFIX_FLOOR` (24) — the brain-local-copy idiom (dataRoot.ts + * header): the hook preview floor below which a prefix match is coincidental. + */ +const HOOK_PREFIX_FLOOR = 24; + +/** Strip a trailing LinkedIn truncation marker so a `…`-suffixed hook still prefix-matches (assemble.ts idiom). */ +function stripTrailingEllipsis(s: string): string { + return s.replace(/(?:…|\.{3})\s*$/, "").trimEnd(); +} + +/** + * Parse the `## Recent Posts` section into structured entries. The format source + * of truth is the WRITER (`updatePostTracking`, state-updater.mjs:116): + * `- [YYYY-MM-DD] "hook" (chars) - topic`. (NOT `pruneContentHistory`'s :145 regex, + * which is date-only.) Capture/`match` only — no `String.replace` — so a `$`-bearing + * hook/topic round-trips verbatim with no injection. Pure. + */ +export function parseRecentPosts(stateText: string): RecentPost[] { + // Line-walk from the `## Recent Posts` heading to the next `## ` heading (or EOF). + // Line-based (not one lookahead regex) so a blank line right after the heading + // can't collapse the capture. + const lines = stateText.split("\n"); + const start = lines.findIndex((l) => /^## Recent Posts[ \t]*$/.test(l)); + if (start === -1) return []; + const entry = /^- \[(\d{4}-\d{2}-\d{2})\] "(.*)" \((\d+)\) - (.+)$/; + const out: RecentPost[] = []; + for (let i = start + 1; i < lines.length; i++) { + if (/^## /.test(lines[i])) break; // next section ends the block + const m = lines[i].match(entry); + if (m) out.push({ date: m[1], hook: m[2], charCount: Number(m[3]), topic: m[4] }); + } + return out; +} + +/** + * Find the published record a silo-1 hook corresponds to: the hook (ellipsis- + * stripped, normalized) must be a PREFIX of the record body, and long enough to + * discriminate. Among matches, prefer one whose `published_date` equals the + * entry's date (deterministic), else the first in input order. + */ +function matchRecord(post: RecentPost, records: PublishedRecord[]): PublishedRecord | undefined { + const nh = stripTrailingEllipsis(normalize(post.hook)); + if (nh.length < HOOK_PREFIX_FLOOR) return undefined; // too short → cannot discriminate + let first: PublishedRecord | undefined; + for (const r of records) { + if (!normalize(r.body).startsWith(nh)) continue; + if (r.published_date === post.date) return r; // exact date wins + if (!first) first = r; + } + return first; +} + +/** + * Reconcile each silo-1 entry against the graph. `records` carry `body` (the join + * key the body-less `PostGraphNode` lacks); `graph` carries each record's analytics + * match. Pure. Tiers: `in-graph` (matched record HAS analytics) · `in-brain-only` + * (matched record, no analytics) · `orphaned-in-state` (no matching record — the + * coverage gap: created but never ingested). + */ +export function reconcileRecentPosts(args: { + recentPosts: RecentPost[]; + records: PublishedRecord[]; + graph: PostGraphNode[]; +}): ReconcileNode[] { + const tierOf = new Map(args.graph.map((g) => [g.contentId, g.match.confidence])); + return args.recentPosts.map((post) => { + const rec = matchRecord(post, args.records); + if (!rec) return { recentPost: post, tier: "orphaned-in-state" as const }; + const hasAnalytics = (tierOf.get(rec.id) ?? "none") !== "none"; + return { + recentPost: post, + tier: hasAnalytics ? ("in-graph" as const) : ("in-brain-only" as const), + contentId: rec.id, + }; + }); +} + +/** Count the coverage tiers. Pure. */ +export function summarizeReconcile(nodes: ReconcileNode[]): ReconcileSummary { + const sum: ReconcileSummary = { created: nodes.length, inGraph: 0, inBrainOnly: 0, orphaned: 0 }; + for (const n of nodes) { + if (n.tier === "in-graph") sum.inGraph++; + else if (n.tier === "in-brain-only") sum.inBrainOnly++; + else sum.orphaned++; + } + return sum; +} + +/** + * Resolve the runtime state file with the canonical precedence (the brain-side + * copy of `hooks/scripts/data-root.mjs: getStateFile()` — `STATE_FILE` first, else + * `~/.claude/linkedin-studio.local.md` with `HOME||USERPROFILE||homedir()`). This + * is a NEW root-skew seam: the state file lives OUTSIDE the brain dataRoot + * (`LINKEDIN_STUDIO_DATA`); if `STATE_FILE` is repointed the read follows it. + */ +function getStateFile(): string { + const home = process.env.HOME || process.env.USERPROFILE || homedir(); + return process.env.STATE_FILE || join(home, ".claude", "linkedin-studio.local.md"); +} + +/** Read-only: load + parse silo 1 from the state file. Absent/unreadable → [] (fresh-clone safe). */ +export function loadRecentPosts(): RecentPost[] { + const file = getStateFile(); + if (!existsSync(file)) return []; + try { + return parseRecentPosts(readFileSync(file, "utf8")); + } catch { + return []; // never crash on a malformed/locked state file + } +} diff --git a/scripts/brain/tests/reconcile.test.ts b/scripts/brain/tests/reconcile.test.ts new file mode 100644 index 0000000..bb999e6 --- /dev/null +++ b/scripts/brain/tests/reconcile.test.ts @@ -0,0 +1,210 @@ +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { mintContentId } from "../src/id.js"; +import type { PublishedRecord } from "../src/ingest.js"; +import { assemblePostGraph, type AnalyticsRowInput } from "../src/assemble.js"; +import { + parseRecentPosts, + reconcileRecentPosts, + summarizeReconcile, + loadRecentPosts, +} from "../src/reconcile.js"; + +const DATE = "2026-05-26"; + +function rec(body: string, over: Partial = {}): PublishedRecord { + return { + id: mintContentId(body), + provenance: "published", + published_date: DATE, + captured_at: "2026-06-23", + source: "manual", + specifics: [], + trends: [], + body, + ...over, + }; +} + +function row(title: string, over: Partial = {}): AnalyticsRowInput { + return { title, publishedDate: DATE, metrics: { engagementRate: 4.2 }, ...over }; +} + +// Bodies whose normalized opener is ≥ HOOK_PREFIX_FLOOR (24) chars. +const BODY_A = "Jeg lærte noe viktig om dømmekraft i dag.\n\nDel 1 av serien."; +const HOOK_A = "Jeg lærte noe viktig om dømmekraft i dag."; +const BODY_B = "En helt annen tekst om automatisering og styring.\n\nDel 2."; +const HOOK_B = "En helt annen tekst om automatisering og styring."; + +// The writer's exact entry format (state-updater.mjs:116): - [date] "hook" (chars) - topic +function entry(date: string, hook: string, chars: number, topic: string): string { + return `- [${date}] "${hook}" (${chars}) - ${topic}`; +} + +function stateWith(...entries: string[]): string { + return [ + "---", + "posts_this_week: 3", + "---", + "", + "## Recent Posts", + "", + ...entries, + "", + "## Milestone Log", + "", + "- [2026-05] 1048 (+10)", + "", + ].join("\n"); +} + +describe("SB-S3e — parseRecentPosts (writer-format reader)", () => { + test("extracts {date, hook, charCount, topic} from the ## Recent Posts section", () => { + const text = stateWith( + entry("2026-05-26", HOOK_A, 1400, "dømmekraft"), + entry("2026-05-20", HOOK_B, 900, "automatisering"), + ); + const posts = parseRecentPosts(text); + assert.equal(posts.length, 2); + assert.deepEqual(posts[0], { date: "2026-05-26", hook: HOOK_A, charCount: 1400, topic: "dømmekraft" }); + assert.deepEqual(posts[1], { date: "2026-05-20", hook: HOOK_B, charCount: 900, topic: "automatisering" }); + }); + + test("golden-string: a literal writer-produced entry round-trips verbatim", () => { + // The byte-exact string updatePostTracking emits (state-updater.mjs:116). + const writerLine = `- [2026-06-01] "${HOOK_A}" (1234) - strategi`; + const posts = parseRecentPosts(["## Recent Posts", "", writerLine, ""].join("\n")); + assert.equal(posts.length, 1); + assert.equal(posts[0].hook, HOOK_A); + assert.equal(posts[0].charCount, 1234); + assert.equal(posts[0].topic, "strategi"); + }); + + test("stops at the next section (## Milestone Log is not parsed as an entry)", () => { + const text = stateWith(entry("2026-05-26", HOOK_A, 1400, "dømmekraft")); + const posts = parseRecentPosts(text); + assert.equal(posts.length, 1); + assert.ok(posts.every((p) => /^\d{4}-\d{2}-\d{2}$/.test(p.date))); + }); + + test("a doc with no ## Recent Posts section → []", () => { + assert.deepEqual(parseRecentPosts("# Some other doc\n\n## Notes\n\n- a line"), []); + }); + + test("$-bearing hook/topic round-trips verbatim (capture on read — no replace, no injection)", () => { + const dollarHook = "Hvorfor $100 i budsjettkutt endrer alt for $deg"; + const text = ["## Recent Posts", "", entry("2026-05-26", dollarHook, 800, "$penger$"), ""].join("\n"); + const posts = parseRecentPosts(text); + assert.equal(posts.length, 1); + assert.equal(posts[0].hook, dollarHook); + assert.equal(posts[0].topic, "$penger$"); + }); +}); + +describe("SB-S3e — reconcileRecentPosts (coverage tiers)", () => { + test("in-graph: created post matches a published record that HAS analytics", () => { + const r = rec(BODY_A); + const graph = assemblePostGraph({ records: [r], analytics: [row(HOOK_A)] }); + const recentPosts = parseRecentPosts( + ["## Recent Posts", "", entry("2026-05-26", HOOK_A, 1400, "dømmekraft"), ""].join("\n"), + ); + const nodes = reconcileRecentPosts({ recentPosts, records: [r], graph }); + assert.equal(nodes.length, 1); + assert.equal(nodes[0].tier, "in-graph"); + assert.equal(nodes[0].contentId, r.id); + }); + + test("in-brain-only: created post matches a record but the record has NO analytics", () => { + const r = rec(BODY_B); + const graph = assemblePostGraph({ records: [r], analytics: [] }); // no analytics → match none + const recentPosts = parseRecentPosts( + ["## Recent Posts", "", entry("2026-05-26", HOOK_B, 900, "automatisering"), ""].join("\n"), + ); + const nodes = reconcileRecentPosts({ recentPosts, records: [r], graph }); + assert.equal(nodes[0].tier, "in-brain-only"); + assert.equal(nodes[0].contentId, r.id); + }); + + test("orphaned-in-state: created post matches NO published record (never ingested)", () => { + const r = rec(BODY_A); + const graph = assemblePostGraph({ records: [r], analytics: [row(HOOK_A)] }); + const recentPosts = parseRecentPosts( + ["## Recent Posts", "", entry("2026-05-19", "En post som aldri ble ingestet i hjernen", 700, "annet"), ""].join("\n"), + ); + const nodes = reconcileRecentPosts({ recentPosts, records: [r], graph }); + assert.equal(nodes[0].tier, "orphaned-in-state"); + assert.equal(nodes[0].contentId, undefined); + }); + + test("truncated-hook (…) degradation: a '…'-suffixed preview still prefix-matches", () => { + const r = rec(BODY_A); + const graph = assemblePostGraph({ records: [r], analytics: [row(HOOK_A)] }); + const recentPosts = parseRecentPosts( + ["## Recent Posts", "", entry("2026-05-26", "Jeg lærte noe viktig om dømmekraft i…", 1400, "dømmekraft"), ""].join("\n"), + ); + const nodes = reconcileRecentPosts({ recentPosts, records: [r], graph }); + assert.equal(nodes[0].tier, "in-graph"); + }); + + test("below-floor hook → orphaned (too short to discriminate, never a false match)", () => { + const r = rec(BODY_A); + const graph = assemblePostGraph({ records: [r], analytics: [row(HOOK_A)] }); + const recentPosts = parseRecentPosts( + ["## Recent Posts", "", entry("2026-05-26", "Jeg lærte", 1400, "dømmekraft"), ""].join("\n"), + ); + const nodes = reconcileRecentPosts({ recentPosts, records: [r], graph }); + assert.equal(nodes[0].tier, "orphaned-in-state"); + }); + + test("summary counts created / in-graph / in-brain-only / orphaned", () => { + const rA = rec(BODY_A); + const rB = rec(BODY_B); + const graph = assemblePostGraph({ records: [rA, rB], analytics: [row(HOOK_A)] }); + const recentPosts = parseRecentPosts( + [ + "## Recent Posts", + "", + entry("2026-05-26", HOOK_A, 1400, "a"), // in-graph (rA has analytics) + entry("2026-05-26", HOOK_B, 900, "b"), // in-brain-only (rB no analytics) + entry("2026-05-10", "Helt ukjent post som ingen record dekker", 600, "c"), // orphaned + "", + ].join("\n"), + ); + const nodes = reconcileRecentPosts({ recentPosts, records: [rA, rB], graph }); + const sum = summarizeReconcile(nodes); + assert.deepEqual(sum, { created: 3, inGraph: 1, inBrainOnly: 1, orphaned: 1 }); + }); + + test("empty ## Recent Posts → empty report, no throw", () => { + const nodes = reconcileRecentPosts({ recentPosts: [], records: [rec(BODY_A)], graph: [] }); + assert.deepEqual(nodes, []); + assert.deepEqual(summarizeReconcile(nodes), { created: 0, inGraph: 0, inBrainOnly: 0, orphaned: 0 }); + }); +}); + +describe("SB-S3e — loadRecentPosts (state-file IO seam, STATE_FILE precedence)", () => { + test("reads the state file pointed at by STATE_FILE; unset/absent → []", () => { + const dir = mkdtempSync(join(tmpdir(), "ls-recon-")); + const file = join(dir, "linkedin-studio.local.md"); + writeFileSync(file, stateWith(entry("2026-05-26", HOOK_A, 1400, "dømmekraft"))); + const prev = process.env.STATE_FILE; + try { + process.env.STATE_FILE = file; + const posts = loadRecentPosts(); + assert.equal(posts.length, 1); + assert.equal(posts[0].hook, HOOK_A); + + // Absent file via STATE_FILE → [] (fresh-clone safe, no throw). + process.env.STATE_FILE = join(dir, "does-not-exist.md"); + assert.deepEqual(loadRecentPosts(), []); + } finally { + if (prev === undefined) delete process.env.STATE_FILE; + else process.env.STATE_FILE = prev; + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/scripts/test-runner.sh b/scripts/test-runner.sh index 7aa785e..19482a4 100755 --- a/scripts/test-runner.sh +++ b/scripts/test-runner.sh @@ -35,8 +35,10 @@ # anti-sycophancy literal 'evidence to TEST', with a non-vacuity self-test) in Section # 16d; the brain operations-reader guard (SB-S3d: strategy-advisor names # brain/operations.md AND carries the frozen-past-self literal 'deprecates older -# inferences', with a non-vacuity self-test) in Section 16e; the assertion-count -# anti-erosion floor (SC6) in Section 18. All are live below (Sections 8–18). +# inferences', with a non-vacuity self-test) in Section 16e; the brain reconcile-wiring +# guard (SB-S3e: scripts/brain/src/cli.ts dispatches `reconcile` AND calls the core +# reconcileRecentPosts by literal name, with a non-vacuity self-test) in Section 16f; +# the assertion-count anti-erosion floor (SC6) in Section 18. All are live below (Sections 8–18). # # Usage: bash scripts/test-runner.sh # bash 3.2-safe: plain arrays only, no `declare -A`, no `mapfile`/`readarray`. @@ -589,7 +591,7 @@ fi # porcelain would hide; filtered to the data classes, it must be empty. Catches a flow # (or a stray file) that wrote user data back into the plugin tree (SC2). personas is # excluded (un-migrated, out of M0 scope — consistent with Section 13's class). -SC2_CLASSES='assets/analytics/(exports|posts|weekly-reports|monthly-reports)/|assets/analytics/content-history\.md|assets/drafts/queue\.json|assets/drafts/week-|assets/voice-samples/[^ ]*\.local\.md|config/user-profile\.local\.md' +SC2_CLASSES='assets/analytics/(exports|posts|weekly-reports|monthly-reports)/|assets/drafts/queue\.json|assets/drafts/week-|assets/voice-samples/[^ ]*\.local\.md|config/user-profile\.local\.md' SC2_DIRT=$(git status --porcelain --ignored 2>/dev/null | grep -E "$SC2_CLASSES" || true) if [ -z "$SC2_DIRT" ]; then pass "SC2 dry-run: no in-plugin user-data files (analytics/drafts/voice/profile) in the tree" @@ -715,7 +717,7 @@ if [ -x "$BR_DIR/node_modules/.bin/tsx" ]; then BR_OUT=$( set +e; (cd "$BR_DIR" && npm test) 2>&1; echo "BR_EXIT:$?" ) BR_EXIT=$(echo "$BR_OUT" | grep -oE 'BR_EXIT:[0-9]+' | grep -oE '[0-9]+' | head -1) BR_TESTS=$(echo "$BR_OUT" | grep -oE 'tests [0-9]+' | grep -oE '[0-9]+' | tail -1) - BRAIN_TESTS_FLOOR=114 # SB-S0 34 [id(11)+profile(6)+fold(12)+scaffold(5)] + SB-S1 29 [ingest(14)+publish(9)+cli(6)] + SB-S2 19 [consolidate(12)+consolidate-cli(7)] + SB-S3b 12 [consolidate(10)+consolidate-cli(2)] + SB-S3c 19 [ingest(4)+publish(3)+assemble(8)+cli(4)] + SB-S3d 1 [scaffold dated-anchor seed] + BRAIN_TESTS_FLOOR=127 # SB-S0 34 [id(11)+profile(6)+fold(12)+scaffold(5)] + SB-S1 29 [ingest(14)+publish(9)+cli(6)] + SB-S2 19 [consolidate(12)+consolidate-cli(7)] + SB-S3b 12 [consolidate(10)+consolidate-cli(2)] + SB-S3c 19 [ingest(4)+publish(3)+assemble(8)+cli(4)] + SB-S3d 1 [scaffold dated-anchor seed] + SB-S3e 13 [reconcile: parse(5)+tiers(7)+io(1)] if [ "$BR_EXIT" = "0" ] && [ -n "$BR_TESTS" ] && [ "$BR_TESTS" -ge "$BRAIN_TESTS_FLOOR" ]; then pass "brain suite green: $BR_TESTS tests pass (floor $BRAIN_TESTS_FLOOR)" else @@ -883,6 +885,56 @@ fi echo "" +# --- Section 16f: Brain Reconcile Wiring (SB-S3e) --- +echo "--- Brain Reconcile Wiring ---" + +# SB-S3e wires `brain reconcile` (read-side triple-post reconciliation: silo 1 +# `## Recent Posts` ↔ the silo 2↔3 graph). The CLI must (a) dispatch the reconcile +# command AND (b) call the pure core by its literal name. Both literals live in +# scripts/brain/src/cli.ts (the dispatch `if` + runReconcile's literal-name call), +# grepped EXACT with grep -F. Non-vacuity self-test mirrors Section 16e: a probe is +# "wired" iff it carries BOTH literals; D1/D2 miss EXACTLY ONE literal (the load- +# bearing discriminators), D3 is a fully-wired SIBLING command (assemble) carrying +# neither reconcile literal (specificity — the predicate is reconcile-specific, not +# "any command + any core"). Wiring is gate-enforced; the reconcile CORE correctness +# is covered by the brain suite (reconcile.test.ts) and the end-to-end run is verified +# manually (SC4/SC6 at land — CLI behaviour is not unit-tested here; brief says so). +RECON_CLI_LIT='command === "reconcile"' +RECON_CORE_LIT='reconcileRecentPosts' + +reconcile_wired() { # $1 = text; wired iff BOTH literals present (echo twice — grep consumes stdin) + echo "$1" | grep -qF "$RECON_CLI_LIT" && echo "$1" | grep -qF "$RECON_CORE_LIT" +} + +RECON_SELFTEST_OK=1 +if ! reconcile_wired 'if (command === "reconcile") return runReconcile via reconcileRecentPosts'; then + RECON_SELFTEST_OK=0; echo " non-vacuity FAIL: a fully-wired probe was not detected" +fi +while IFS= read -r probe; do + [ -z "$probe" ] && continue + if reconcile_wired "$probe"; then + RECON_SELFTEST_OK=0; echo " false-positive FAIL: under-wired probe accepted -> $probe" + fi +done <<'NEGATIVE16F' +if (command === "reconcile") return runReconcile(flags) but never calls the core +const nodes = reconcileRecentPosts({ recentPosts, records, graph }) with no command dispatch +if (command === "assemble") return runAssemble(flags) using assemblePostGraph +NEGATIVE16F +if [ "$RECON_SELFTEST_OK" -eq 1 ]; then + pass "reconcile self-test: full wiring detected; D1/D2 single-literal forms + D3 assemble specificity decoy rejected" +else + fail "reconcile self-test failed — the reconcile-wiring lint is vacuous or over-eager" +fi + +CLI_RECON="scripts/brain/src/cli.ts" +if grep -qF "$RECON_CLI_LIT" "$CLI_RECON" && grep -qF "$RECON_CORE_LIT" "$CLI_RECON"; then + pass "brain CLI wired to reconcile (dispatch + core call by literal name)" +else + fail "brain CLI missing reconcile wiring — needs both '$RECON_CLI_LIT' and '$RECON_CORE_LIT' in $CLI_RECON" +fi + +echo "" + # --- Section 17: De-Niche Guard (B-S1 + B-S2) --- echo "--- De-Niche Guard ---" @@ -958,12 +1010,13 @@ echo "" # checks (published-only self-test + voice-trainer grep + contract-doc grep) = 78; # +2 for SB-S3a's two UNCONDITIONAL Section-16d checks (profile-reader self-test + # strategy-advisor wiring grep) = 80; +2 for SB-S3d's two UNCONDITIONAL Section-16e -# checks (ops-reader self-test + strategy-advisor ops-wiring grep) = 82. +# checks (ops-reader self-test + strategy-advisor ops-wiring grep) = 82; +2 for SB-S3e's +# two UNCONDITIONAL Section-16f checks (reconcile self-test + brain-CLI reconcile grep) = 84. # NB: the floor tracks the deps-absent MINIMUM (conditional TS suites warn-skip and drop # the count), so it is bumped only by UNCONDITIONAL new checks — NOT pinned to the # deps-present TOTAL_CHECKS (that would zero the warn-skip margin and false-fail a fresh # clone). Runs last so TOTAL_CHECKS sees every prior check. -ASSERT_BASELINE_FLOOR=82 +ASSERT_BASELINE_FLOOR=84 TOTAL_CHECKS=$((PASS + FAIL)) if [ "$TOTAL_CHECKS" -ge "$ASSERT_BASELINE_FLOOR" ]; then pass "assertion-count anti-erosion: $TOTAL_CHECKS checks >= baseline floor $ASSERT_BASELINE_FLOOR"