feat(linkedin-studio): specifics-bank store — lived-specifics inventory (fix #2 slice 1)

Fix #2 (kilde-så-draft) slice 1: the deterministic STORE half of the
lived-specifics bank — the operator's real, un-generatable raw material
(measured number / named-but-real case / what broke / contrarian opinion /
mind-change), so drafts start grounded instead of hollow (retning §3;
dream-spec "Lived-Specifics Extraction", the single biggest missing upstream
piece for the authenticity thesis).

scripts/specifics-bank/ (sibling to contract-gate, same tsx convention):
- src/types.ts — Specific/Bank schema (schemaVersion 1), un-generatable taxonomy
- src/bank.ts — pure store: normalizeContent, content-hash id (= dedupe key),
  load/save, addSpecific (dedupe + topicTag union on re-capture), queryByTopic
  (active-only, ranked by tag overlap then recency)
- src/cli.ts — query / add / list; default bank under
  ${LINKEDIN_STUDIO_DATA:-~/.claude/linkedin-studio}/specifics-bank/ so it
  survives plugin upgrades/reinstalls (M0 data-path seam)
- tests/bank.test.ts — 15/15 green; tsc clean
- README + .gitignore for node_modules/build

Content is stored verbatim; never AI-generated. The elicitation interview
(refuses vague answers; abstrakt/ekstern escape hatches) lands in slice 3
(/linkedin:newsletter Step 1.5). Forward-compatible with the profile-evolution
/ "second brain" architecture.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-06-20 21:03:18 +02:00
commit b390b87ad5
9 changed files with 1239 additions and 0 deletions

View file

@ -0,0 +1,153 @@
/**
* Deterministic store + query for the lived-specifics bank (Fix #2).
*
* Pure where it matters: id derivation, dedupe, and topic query are
* side-effect-free and fully testable. Only loadBank/saveBank touch the
* filesystem. No AI, no network the bank is reliable inventory, not a
* creatively-interpreted blob. The elicitation that POPULATES the bank lives in
* the command layer (a guided interview that refuses vague answers); this module
* only stores, dedupes, and serves what the operator actually supplied.
*/
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
import { dirname, join } from "node:path";
import { homedir } from "node:os";
import { createHash } from "node:crypto";
import { SCHEMA_VERSION } from "./types.js";
import type {
Bank,
Provenance,
QueryHit,
Specific,
SpecificStatus,
SpecificType,
Verification,
} from "./types.js";
export { SCHEMA_VERSION } from "./types.js";
/** What a caller supplies to addSpecific — the id is derived, never passed in. */
export interface SpecificInput {
type: SpecificType;
/** The operator's raw material, verbatim. */
content: string;
topicTags: string[];
provenance: Provenance;
/** Defaults to "unverified" (numbers stay guilty-until-checked — regel 6/7). */
verification?: Verification;
/** Defaults to "active". */
status?: SpecificStatus;
}
export interface AddResult {
bank: Bank;
/** true iff a new specific was appended (false = duplicate content). */
added: boolean;
/** true iff an existing duplicate gained new topic tags via union. */
merged: boolean;
}
/** Lowercase + trim + collapse all whitespace runs to a single space. */
export function normalizeContent(content: string): string {
return content.trim().toLowerCase().replace(/\s+/g, " ");
}
/** Stable id = first 12 hex of sha256(normalizeContent) — also the dedupe key. */
export function specificId(content: string): string {
return createHash("sha256").update(normalizeContent(content)).digest("hex").slice(0, 12);
}
export function emptyBank(): Bank {
return { schemaVersion: SCHEMA_VERSION, specifics: [] };
}
/** Read the bank; a missing file is an empty bank (never throws on absence). */
export function loadBank(path: string): Bank {
if (!existsSync(path)) return emptyBank();
const parsed = JSON.parse(readFileSync(path, "utf8")) as Partial<Bank>;
return {
schemaVersion: parsed.schemaVersion ?? SCHEMA_VERSION,
specifics: Array.isArray(parsed.specifics) ? parsed.specifics : [],
};
}
/** Write the bank as pretty JSON, creating the parent dir if needed. */
export function saveBank(path: string, bank: Bank): void {
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, JSON.stringify(bank, null, 2) + "\n", "utf8");
}
/** Union of two tag lists, order-stable on the first list, case-insensitive dedupe. */
function unionTags(existing: string[], incoming: string[]): { tags: string[]; changed: boolean } {
const seen = new Set(existing.map((t) => t.toLowerCase()));
const tags = [...existing];
let changed = false;
for (const t of incoming) {
if (!seen.has(t.toLowerCase())) {
seen.add(t.toLowerCase());
tags.push(t);
changed = true;
}
}
return { tags, changed };
}
/**
* Add a specific, deduping on normalized content. A duplicate does not append a
* second entry instead its topic tags are unioned in, so the same real
* material re-surfaced under a new edition's topic enriches the existing record.
* The operator's `content` is stored VERBATIM; only the id is normalized.
*/
export function addSpecific(bank: Bank, input: SpecificInput): AddResult {
const id = specificId(input.content);
const existing = bank.specifics.find((s) => s.id === id);
if (existing) {
const { tags, changed } = unionTags(existing.topicTags, input.topicTags);
existing.topicTags = tags;
return { bank, added: false, merged: changed };
}
const specific: Specific = {
id,
type: input.type,
content: input.content,
topicTags: [...input.topicTags],
provenance: input.provenance,
verification: input.verification ?? "unverified",
status: input.status ?? "active",
};
bank.specifics.push(specific);
return { bank, added: true, merged: false };
}
/**
* Active specifics whose tags overlap the query, ranked by overlap (desc) then
* recency (capturedAt desc). Tag matching is case-insensitive. Non-matches and
* archived material are excluded.
*/
export function queryByTopic(bank: Bank, tags: string[]): QueryHit[] {
const wanted = tags.map((t) => t.toLowerCase());
const hits: QueryHit[] = [];
for (const specific of bank.specifics) {
if (specific.status !== "active") continue;
const have = new Set(specific.topicTags.map((t) => t.toLowerCase()));
const tagOverlap = wanted.reduce((n, t) => (have.has(t) ? n + 1 : n), 0);
if (tagOverlap > 0) hits.push({ specific, tagOverlap });
}
hits.sort(
(a, b) =>
b.tagOverlap - a.tagOverlap ||
b.specific.provenance.capturedAt.localeCompare(a.specific.provenance.capturedAt),
);
return hits;
}
/**
* Default bank path under the per-user data dir (M0 data-path convention), so the
* bank survives plugin upgrades/reinstalls. `LINKEDIN_STUDIO_DATA` overrides the
* root; otherwise `~/.claude/linkedin-studio`.
*/
export function defaultBankPath(): string {
const root = process.env.LINKEDIN_STUDIO_DATA ?? join(homedir(), ".claude", "linkedin-studio");
return join(root, "specifics-bank", "specifics-bank.json");
}

View file

@ -0,0 +1,151 @@
#!/usr/bin/env node
/**
* CLI for the lived-specifics bank (Fix #2 kilde--draft).
*
* node --import tsx src/cli.ts query --tags <a,b> [--bank <path>] [--json]
* node --import tsx src/cli.ts add --type <t> --content "<text>" --tags <a,b>
* [--source <s>] [--verification <v>] [--bank <path>]
* node --import tsx src/cli.ts list [--bank <path>] [--json]
*
* The command layer (Step 1.5) calls `query` to surface inventory the operator
* already has for an edition's topics, and `add` to fold newly-elicited material
* back in. Deterministic store; the elicitation interview itself lives upstream.
*
* Exit code: 0 on success, 2 on usage error.
*/
import {
addSpecific,
defaultBankPath,
loadBank,
queryByTopic,
saveBank,
} from "./bank.js";
import type { SpecificType, Verification } from "./types.js";
const VALID_TYPES: SpecificType[] = [
"number",
"named-case",
"what-broke",
"contrarian",
"mind-change",
"other",
];
const VALID_VERIFICATION: Verification[] = ["verified", "unverified", "n/a"];
function parseFlags(args: string[]): Record<string, string> {
const out: Record<string, string> = {};
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (a.startsWith("--")) {
const key = a.slice(2);
const next = args[i + 1];
if (next === undefined || next.startsWith("--")) {
out[key] = "true";
} else {
out[key] = next;
i++;
}
}
}
return out;
}
function splitTags(raw: string | undefined): string[] {
if (!raw) return [];
return raw
.split(",")
.map((t) => t.trim())
.filter((t) => t.length > 0);
}
function usage(msg: string): never {
console.error(`error: ${msg}`);
console.error(
"usage:\n" +
" query --tags <a,b> [--bank <path>] [--json]\n" +
' add --type <t> --content "<text>" --tags <a,b> [--source <s>] [--verification <v>] [--bank <path>]\n' +
" list [--bank <path>] [--json]",
);
process.exit(2);
}
function today(): string {
return new Date().toISOString().slice(0, 10);
}
function main(): void {
const [command, ...rest] = process.argv.slice(2);
const flags = parseFlags(rest);
const bankPath = flags.bank ?? defaultBankPath();
const asJson = flags.json === "true";
if (command === "query") {
const tags = splitTags(flags.tags);
if (tags.length === 0) usage("query needs --tags <a,b>");
const hits = queryByTopic(loadBank(bankPath), tags);
if (asJson) {
console.log(JSON.stringify(hits, null, 2));
return;
}
if (hits.length === 0) {
console.log(`No lived specifics found for tags: ${tags.join(", ")}`);
console.log("→ elicit fresh material from the operator (Step 1.5), then `add` it.");
return;
}
console.log(`${hits.length} lived specific(s) for: ${tags.join(", ")}`);
for (const { specific, tagOverlap } of hits) {
const v = specific.verification === "verified" ? "" : ` [${specific.verification}]`;
console.log(`\n · (${specific.type}, overlap ${tagOverlap})${v} ${specific.content}`);
console.log(` tags: ${specific.topicTags.join(", ")} — from ${specific.provenance.source}`);
}
return;
}
if (command === "add") {
const type = flags.type as SpecificType;
if (!VALID_TYPES.includes(type)) usage(`--type must be one of: ${VALID_TYPES.join(", ")}`);
const content = flags.content;
if (!content || content === "true") usage("add needs --content \"<text>\"");
const tags = splitTags(flags.tags);
if (tags.length === 0) usage("add needs --tags <a,b>");
const verification = (flags.verification as Verification) ?? "unverified";
if (!VALID_VERIFICATION.includes(verification)) {
usage(`--verification must be one of: ${VALID_VERIFICATION.join(", ")}`);
}
const bank = loadBank(bankPath);
const res = addSpecific(bank, {
type,
content,
topicTags: tags,
provenance: { capturedAt: today(), source: flags.source ?? "manual" },
verification,
});
saveBank(bankPath, res.bank);
if (res.added) {
console.log(`Added (${type}): ${content}`);
} else {
console.log(`Duplicate — already in bank${res.merged ? " (tags unioned)" : ""}: ${content}`);
}
console.log(`Bank: ${bankPath} (${res.bank.specifics.length} specifics)`);
return;
}
if (command === "list") {
const bank = loadBank(bankPath);
if (asJson) {
console.log(JSON.stringify(bank, null, 2));
return;
}
console.log(`Bank: ${bankPath}${bank.specifics.length} specific(s)`);
for (const s of bank.specifics) {
const flag = s.status === "active" ? "" : ` (${s.status})`;
console.log(` · [${s.type}]${flag} ${s.content} {${s.topicTags.join(", ")}}`);
}
return;
}
usage(command ? `unknown command: ${command}` : "no command given");
}
main();

View file

@ -0,0 +1,72 @@
/**
* Types for the lived-specifics bank (Fix #2 kilde--draft).
*
* The bank is the operator's inventory of REAL, un-generatable raw material:
* the specific measured number, the named (anonymized-but-real) case, the thing
* that actually broke, the unfashionable opinion privately held, the moment they
* changed their mind. The taxonomy mirrors the dream-spec's "Lived-Specifics
* Extraction" engine (docs/expert-review/dream-spec.local.md): sourcing, not
* styling, is where authenticity is won so drafts must draw from real
* inventory instead of inventing plausible filler.
*
* INVARIANT (retning §3 «ditt råmateriale, verktøyets håndverk»): every
* `content` is the OPERATOR's own words, captured through elicitation. The bank
* never holds AI-generated lived experience. The store half lives here
* (deterministic, testable); the elicitation interview lives in the command.
*
* Forward-compatible with the profile-evolution / "second brain" architecture:
* a typed, tag- and provenance-bearing knowledge store in the per-user data dir
* (`${LINKEDIN_STUDIO_DATA}`), so it survives plugin upgrades/reinstalls via the
* existing M0 data-path seam. New `SpecificType`s or cross-references can be
* added without breaking the shape.
*/
/** The un-generatable taxonomy (dream-spec line 56). */
export type SpecificType =
| "number" // a real, measured number (must be fact-checked — regel 6/7)
| "named-case" // a named (anonymized-but-real) client / project / person
| "what-broke" // the thing that actually went wrong / broke
| "contrarian" // an unfashionable opinion the operator privately holds
| "mind-change" // a moment the operator changed their mind
| "other"; // real material that does not fit the above
/** Whether a checkable claim (esp. a number) has been verified to a source. */
export type Verification = "verified" | "unverified" | "n/a";
/** Active material is offered by query; archived material is kept but not offered. */
export type SpecificStatus = "active" | "archived";
export interface Provenance {
/** ISO-8601 date the material was captured. Supplied by the caller (CLI edge). */
capturedAt: string;
/** Where it was captured: an edition id (e.g. "seres/05"), a command, or "manual". */
source: string;
}
export interface Specific {
/** Stable id — a short hash of the normalized content; doubles as the dedupe key. */
id: string;
type: SpecificType;
/** The operator's raw material, in their words. NEVER AI-generated. */
content: string;
/** Topic tags for query-by-topic. Unioned across re-captures of the same content. */
topicTags: string[];
provenance: Provenance;
/** Numbers must be fact-checked before a draft asserts them as fact (regel 6/7). */
verification: Verification;
status: SpecificStatus;
}
export interface Bank {
schemaVersion: number;
specifics: Specific[];
}
/** A query hit: the specific plus how many of the queried tags it matched. */
export interface QueryHit {
specific: Specific;
/** Number of queried tags present on the specific (≥1 for a hit). */
tagOverlap: number;
}
export const SCHEMA_VERSION = 1;