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:
parent
fbad29d3d4
commit
68f6283d8a
12 changed files with 487 additions and 54 deletions
|
|
@ -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 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<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· ${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");
|
||||
}
|
||||
|
|
|
|||
164
scripts/brain/src/reconcile.ts
Normal file
164
scripts/brain/src/reconcile.ts
Normal 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
|
||||
}
|
||||
}
|
||||
210
scripts/brain/tests/reconcile.test.ts
Normal file
210
scripts/brain/tests/reconcile.test.ts
Normal file
|
|
@ -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> = {}): 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> = {}): 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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue