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:
parent
ff39d14206
commit
88356b8a83
2 changed files with 212 additions and 2 deletions
|
|
@ -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<string, string> {
|
||||
|
|
@ -109,6 +129,98 @@ function runPublished(rest: string[], flags: Record<string, string>): 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<string, string>): 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<typeof r> => 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 <file.json>`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (flags.propose === "true") {
|
||||
const cf = flags.candidates;
|
||||
if (!cf || cf === "true") usage("--propose needs --candidates <file.json>");
|
||||
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 <file.json>");
|
||||
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 <f> | --apply --diff <f> --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");
|
||||
}
|
||||
|
|
|
|||
97
scripts/brain/tests/consolidate-cli.test.ts
Normal file
97
scripts/brain/tests/consolidate-cli.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue