feat(linkedin-studio): SB-S2 brain consolidate CLI — gather/propose/apply [skip-docs]

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 <json> --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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RigJBiRFNtFZKCz21qNbQ4
This commit is contained in:
Kjell Tore Guttormsen 2026-06-23 17:03:34 +02:00
commit 88356b8a83
2 changed files with 212 additions and 2 deletions

View file

@ -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);
});
});