From 88356b8a834ef3f12295c8284f96fbd403a80815 Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Tue, 23 Jun 2026 17:03:34 +0200 Subject: [PATCH] =?UTF-8?q?feat(linkedin-studio):=20SB-S2=20brain=20consol?= =?UTF-8?q?idate=20CLI=20=E2=80=94=20gather/propose/apply=20[skip-docs]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operator-invoked, operator-gated loop: - --gather reads published bodies directly (parsePublishedRecord, filtered by published_date > last_run) + current profile, for the session to extract candidates. - --propose validates candidates (shape + single-line key/value, else non-zero/no-write), proposeDiff, writes brain/pending-diff.{json,md}; never touches profile.md. - --apply --diff --confirm is the ONLY path that writes profile.md (refuses without --confirm), then records brain/consolidation-state.json last_run. init/ingest/published preserved. 7 subprocess CLI tests (SC5). brain 75→82, tsc clean. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01RigJBiRFNtFZKCz21qNbQ4 --- scripts/brain/src/cli.ts | 117 +++++++++++++++++++- scripts/brain/tests/consolidate-cli.test.ts | 97 ++++++++++++++++ 2 files changed, 212 insertions(+), 2 deletions(-) create mode 100644 scripts/brain/tests/consolidate-cli.test.ts diff --git a/scripts/brain/src/cli.ts b/scripts/brain/src/cli.ts index 54d0ee5..36cd00f 100644 --- a/scripts/brain/src/cli.ts +++ b/scripts/brain/src/cli.ts @@ -14,11 +14,31 @@ * Exit code: 0 on success, 2 on usage error. */ -import { readFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { + applyDiff, + proposeDiff, + readConsolidationState, + writeConsolidationState, + type Candidate, + type ProfileDiff, +} from "./consolidate.js"; import { dataRoot } from "./dataRoot.js"; -import { ingestText, listPublished, scanInbox } from "./ingest.js"; +import { ingestText, listPublished, parsePublishedRecord, scanInbox } from "./ingest.js"; +import { parseProfile, serializeProfile } from "./profile.js"; import { initBrain } from "./scaffold.js"; +import { SCHEMA_VERSION } from "./types.js"; +import type { ProfileDoc } from "./types.js"; + +const PROVENANCES = ["human", "published", "ai-draft"]; +const EMPTY_PROFILE: ProfileDoc = { schemaVersion: SCHEMA_VERSION, static: [], dynamic: [] }; + +function loadProfile(): ProfileDoc { + const p = dataRoot(join("brain", "profile.md")); + return existsSync(p) ? parseProfile(readFileSync(p, "utf8")) : EMPTY_PROFILE; +} /** Parse `--key value` / boolean `--flag` args (the specifics-bank CLI idiom). */ function parseFlags(args: string[]): Record { @@ -109,6 +129,98 @@ function runPublished(rest: string[], flags: Record): void { } } +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[]) => { + lines.push(`## ${title} (${items.length})`, ""); + for (const i of items) lines.push(`- ${i}`); + lines.push(""); + }; + section("Additions", diff.additions.map((f) => `${f.value} [${f.provenance}, id ${f.id}]`)); + section("Evidence-bumps", diff.evidenceBumps.map((b) => `${b.id} → count ${b.newCount}`)); + section("Promotions (dynamic→static)", diff.promotions.map((p) => p.id)); + section("⚠ Conflicts (both kept)", diff.conflicts.map((c) => `"${c.primaryValue}" (${c.primaryId}) vs new (${c.altId})`)); + section("Stale (dynamic, >decay)", diff.staleFlags.map((s) => `${s.id} — last seen ${s.last_seen} (${s.daysStale}d)`)); + return lines.join("\n") + "\n"; +} + +function validateCandidates(raw: unknown): Candidate[] { + if (!Array.isArray(raw)) usage("candidates file must be a JSON array"); + (raw as unknown[]).forEach((c: any, i) => { + for (const k of ["key", "value", "source", "observed_date"]) { + if (typeof c?.[k] !== "string" || c[k] === "") usage(`candidate ${i}: missing/empty "${k}"`); + } + if (!PROVENANCES.includes(c.provenance)) usage(`candidate ${i}: provenance must be one of ${PROVENANCES.join(", ")}`); + if (/[\n\r]/.test(c.key) || /[\n\r]/.test(c.value)) usage(`candidate ${i}: key/value must be single-line (no newline/CR)`); + }); + return raw as Candidate[]; +} + +function runConsolidate(flags: Record): void { + if (flags.gather === "true") { + const { last_run } = readConsolidationState(); + 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 fresh = records.filter((r) => last_run === null || r.published_date > last_run); + const profile = loadProfile(); + if (flags.json === "true") { + console.log(JSON.stringify({ since: last_run, published: fresh.map((r) => ({ id: r.id, published_date: r.published_date, body: r.body })), profileFacts: [...profile.static, ...profile.dynamic] }, null, 2)); + return; + } + console.log(`Consolidation gather — ${fresh.length} new published record(s) since ${last_run ?? "the beginning"}:`); + for (const r of fresh) { + console.log(`\n## ${r.id} (${r.published_date})`); + console.log(r.body); + } + console.log(`\nCurrent profile: ${profile.static.length} static + ${profile.dynamic.length} dynamic fact(s).`); + console.log(`\n→ Extract a Candidate[] from the above, then: brain consolidate --propose --candidates `); + return; + } + + if (flags.propose === "true") { + const cf = flags.candidates; + if (!cf || cf === "true") usage("--propose needs --candidates "); + let raw: unknown; + try { raw = JSON.parse(readFileSync(cf, "utf8")); } catch { usage("candidates file is not valid JSON"); } + const candidates = validateCandidates(raw); + const diff = proposeDiff({ current: loadProfile(), candidates, today: today() }); + mkdirSync(dataRoot("brain"), { recursive: true }); + const jsonPath = dataRoot(join("brain", "pending-diff.json")); + const mdPath = dataRoot(join("brain", "pending-diff.md")); + writeFileSync(jsonPath, JSON.stringify(diff, null, 2) + "\n", "utf8"); + writeFileSync(mdPath, renderDiffMd(diff), "utf8"); + console.log(`Wrote ${jsonPath}\nWrote ${mdPath}`); + console.log(`Review ${mdPath}, then: brain consolidate --apply --diff ${jsonPath} --confirm`); + return; + } + + if (flags.apply === "true") { + const df = flags.diff; + if (!df || df === "true") usage("--apply needs --diff "); + if (flags.confirm !== "true") { + console.error("error: refusing to apply without --confirm (operator gate)"); + process.exit(1); + } + const diff = JSON.parse(readFileSync(df, "utf8")) as ProfileDiff; + const next = applyDiff(loadProfile(), diff); + const profilePath = dataRoot(join("brain", "profile.md")); + mkdirSync(dirname(profilePath), { recursive: true }); + writeFileSync(profilePath, serializeProfile(next), "utf8"); + writeConsolidationState(today()); + console.log(`Applied diff to ${profilePath}; consolidation recorded (last_run = ${today()}).`); + return; + } + + usage("consolidate needs --gather | --propose --candidates | --apply --diff --confirm"); +} + function main(): void { const [command, ...rest] = process.argv.slice(2); const flags = parseFlags(rest); @@ -116,6 +228,7 @@ function main(): void { if (command === "init") return runInit(); if (command === "ingest") return runIngest(flags); if (command === "published") return runPublished(rest, flags); + if (command === "consolidate") return runConsolidate(flags); usage(command ? `unknown command: ${command}` : "no command given"); } diff --git a/scripts/brain/tests/consolidate-cli.test.ts b/scripts/brain/tests/consolidate-cli.test.ts new file mode 100644 index 0000000..c9d7f80 --- /dev/null +++ b/scripts/brain/tests/consolidate-cli.test.ts @@ -0,0 +1,97 @@ +import { describe, test, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { tmpdir } from "node:os"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const CLI = join(HERE, "..", "src", "cli.ts"); + +function runCli(root: string, args: string[]): { stdout: string; code: number } { + try { + const stdout = execFileSync("node", ["--import", "tsx", CLI, ...args], { + env: { ...process.env, LINKEDIN_STUDIO_DATA: root }, + encoding: "utf8", + }); + return { stdout, code: 0 }; + } catch (e: any) { + return { stdout: (e.stdout ?? "") + (e.stderr ?? ""), code: e.status ?? 1 }; + } +} + +const candidatesFile = (root: string, items: unknown): string => { + const p = join(root, "candidates.json"); + writeFileSync(p, JSON.stringify(items), "utf8"); + return p; +}; +const profilePath = (root: string) => join(root, "brain", "profile.md"); +const pendingJson = (root: string) => join(root, "brain", "pending-diff.json"); +const pendingMd = (root: string) => join(root, "brain", "pending-diff.md"); +const validCand = { key: "topic", value: "AI safety", provenance: "published", source: "manual", observed_date: "2026-06-23" }; + +describe("brain consolidate CLI (SC5)", () => { + let root: string; + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "brain-consol-")); + runCli(root, ["init"]); // seed brain/profile.md + }); + afterEach(() => rmSync(root, { recursive: true, force: true })); + + test("--gather reads published bodies (filtered by last_run) and writes nothing", () => { + runCli(root, ["ingest", "--file", (() => { const f = join(root, "p.md"); writeFileSync(f, "A post about AI safety governance.", "utf8"); return f; })()]); + const before = readFileSync(profilePath(root), "utf8"); + const { stdout, code } = runCli(root, ["consolidate", "--gather"]); + assert.equal(code, 0); + assert.match(stdout, /AI safety governance/, "gather surfaces the published body"); + assert.equal(readFileSync(profilePath(root), "utf8"), before, "gather leaves profile.md unchanged"); + }); + + test("--propose writes pending-diff.{md,json}, leaves profile.md unchanged", () => { + const before = readFileSync(profilePath(root), "utf8"); + const { code } = runCli(root, ["consolidate", "--propose", "--candidates", candidatesFile(root, [validCand])]); + assert.equal(code, 0); + assert.ok(existsSync(pendingJson(root)), "pending-diff.json written"); + assert.ok(existsSync(pendingMd(root)), "pending-diff.md written"); + assert.equal(readFileSync(profilePath(root), "utf8"), before, "propose leaves profile.md unchanged"); + const diff = JSON.parse(readFileSync(pendingJson(root), "utf8")); + assert.equal(diff.additions.length, 1); + }); + + test("--propose REJECTS a malformed candidate (missing field), no write", () => { + const { code } = runCli(root, ["consolidate", "--propose", "--candidates", candidatesFile(root, [{ key: "x", value: "y" }])]); + assert.notEqual(code, 0, "non-zero exit on malformed candidate"); + assert.ok(!existsSync(pendingJson(root)), "no diff written"); + }); + + test("--propose REJECTS a candidate whose value contains a newline, no write", () => { + const bad = { ...validCand, value: "line one\nline two" }; + const { code } = runCli(root, ["consolidate", "--propose", "--candidates", candidatesFile(root, [bad])]); + assert.notEqual(code, 0, "non-zero exit on multiline value"); + assert.ok(!existsSync(pendingJson(root)), "no diff written"); + }); + + test("--apply --confirm writes profile.md + consolidation-state.json", () => { + runCli(root, ["consolidate", "--propose", "--candidates", candidatesFile(root, [validCand])]); + const { code } = runCli(root, ["consolidate", "--apply", "--diff", pendingJson(root), "--confirm"]); + assert.equal(code, 0); + assert.match(readFileSync(profilePath(root), "utf8"), /AI safety/, "fact applied to profile.md"); + assert.ok(existsSync(join(root, "brain", "consolidation-state.json")), "last-run sidecar written"); + }); + + test("--apply WITHOUT --confirm refuses and writes nothing", () => { + runCli(root, ["consolidate", "--propose", "--candidates", candidatesFile(root, [validCand])]); + const before = readFileSync(profilePath(root), "utf8"); + const { code } = runCli(root, ["consolidate", "--apply", "--diff", pendingJson(root)]); + assert.notEqual(code, 0, "refuses without --confirm"); + assert.equal(readFileSync(profilePath(root), "utf8"), before, "profile.md untouched"); + }); + + test("init/ingest/published still work (regression)", () => { + assert.equal(runCli(root, ["init"]).code, 0); + const f = join(root, "q.md"); writeFileSync(f, "another post", "utf8"); + assert.equal(runCli(root, ["ingest", "--file", f]).code, 0); + assert.equal(runCli(root, ["published", "list"]).code, 0); + }); +});