feat(linkedin-studio): SB-S3e — retire dead content-history + read-side brain reconcile [skip-docs]

The LAST second-brain slice; the S0–S3e arc is now complete.

(b) Retire the dead, zero-reader content-history.md across its 8 plumbing
surfaces: the flaky Stop-hook writer prose, the template (git-rm'd), the
migrate-data.mjs B1 MOVE entry + its test assertions, .gitignore, the gate
SC2_CLASSES guard, the data-path ref-doc, and a session-start comment. SC1
grep = 0; migrate suite 5/5 (R8 idempotency demonstrated).

(c) `brain reconcile` — read-side triple-post reconciliation joining silo 1
(## Recent Posts, auto-tracked creation) to the silo 2↔3 graph, surfacing the
coverage gap: posts created via the plugin but never `brain ingest`-ed. New
pure core scripts/brain/src/reconcile.ts (parseRecentPosts tracks the WRITER
format state-updater.mjs:116, NOT the date-only pruner; reconcileRecentPosts
matches hook→record.body→graph for the in-graph/in-brain-only/orphaned tiers;
loadRecentPosts reads STATE_FILE via the canonical getStateFile() chain — a new
cross-seam read, the state file lives outside the brain dataRoot). Wired as the
`brain reconcile` CLI subcommand (inline parsePublishedRecord loader, not the
body-less listPublished). Read-only: never writes the state silo.

Tests (verified live): gate 99/0/0 (ASSERT floor 82→84; new Section 16f
self-test + CLI grep) · brain 127/127 (floor 114→127, +13 reconcile) · hook
suite 136/136. SC4/SC6 end-to-end run is a real behavioural pass (STATE_FILE
seam read + fallback). Honest limit: read-side cannot reconstruct un-captured
specifics/trends — auto-capture is the flagged follow-up.

Brief/plan: docs/second-brain/{brief,plan}-sb-s3e.md (fbad29d). Go-before-code
gate cleared (operator: retire · read-side · build-now).

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:
Kjell Tore Guttormsen 2026-06-23 22:13:33 +02:00
commit 68f6283d8a
12 changed files with 487 additions and 54 deletions

View file

@ -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 <path> [--source <s>] [--date <YYYY-MM-DD>] [--specific <id>]… [--trend <id>]…\n" +
" ingest --scan-inbox [--source <s>]\n" +
" published list [--json]\n" +
" assemble",
" assemble\n" +
" reconcile",
);
process.exit(2);
}
@ -200,6 +202,52 @@ function runAssemble(_flags: Record<string, string>): void {
}
}
/**
* SB-S3e: read-only triple-post reconciliation silo 1 (`## Recent Posts`,
* auto-tracked creation) vs the silo 23 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<string, string>): 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<typeof r> => 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.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 <p> [--specific <id>] [--trend <id>]\` 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");
}

View file

@ -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/<id>.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
}
}