linkedin-studio/docs/second-brain/plan-sb-s0.md
Kjell Tore Guttormsen d6acb5d36c docs(linkedin-studio): second-brain SB-S0 brief + plan (review-hardened)
Light-Voyage process artifacts for the first second-brain slice (SB-S0):

- brief.md — task brief, revised after adversarial brief-review (B1 fold-source
  premise, B2 pathguard claim [later verified: STATE was right, reviewer wrong],
  M1 floors-as->=, M2 scaffold trigger, M3 grammar contract, M4 runtime-only).
- plan-sb-s0.md — SB-S0 implementation plan, revised after adversarial
  plan-review (plan-critic + scope-guardian, both code-verified):
  B1 test convention -> real TS idiom (tests/*.test.ts via tsx, .js specifiers);
  M2 profile format -> defined line-grammar (repo has no YAML parser, no dep);
  M3 data-root -> inline per-package resolver (repo idiom, not a new seam);
  M4 fold extraction rule pinned against the actual user-profile template;
  + minors (gate-count rationale, npm-install-before-gate, provenance throws,
  stable-slug id, push-confirm).

scope-guardian: zero creep, zero MAJOR gap. plan-critic: B1+3 MAJOR, all folded.
Design phase, no code yet — awaiting operator go to build SB-S0 (TDD).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RigJBiRFNtFZKCz21qNbQ4
2026-06-23 13:24:18 +02:00

14 KiB
Raw Blame History

Implementation Plan — SB-S0 (Foundation)

Status: revised after adversarial plan-review (plan-critic + scope-guardian, both code-verified) → all findings folded in. Ready for operator approval. Brief: brief.md §5§10. Architecture: architecture.md. Scope: SB-S0 only — the brain/ scaffold, the two-layer profile.md + lossless user-profile fold, the entity-id/provenance module, the ingest/ seam dirs. No loop, no ingest logic, no connector, no cross-silo threading, no session-start wiring (brief §7). Review delta (2026-06-23): B1 test convention → real TS idiom (tests/*.test.ts via tsx, .js specifiers); M2 profile format → defined line-grammar (no YAML dep); M3 data-root → inline per-package resolver (repo idiom, not a new seam); M4 fold extraction rule pinned against the real template; m1 gate rationale corrected; m2 npm-install-before-gate; m3 provenance throws; m4 id keyed on stable slug; G1 push-confirm; C1 identity now whole-file.


1. What SB-S0 delivers (recap)

A new TS package scripts/brain/ (an exact structural copy of scripts/specifics-bank/) exposing an idempotent brain init CLI that scaffolds the brain/ + ingest/ tree at the runtime data-path, a pinned two-layer profile.md line-grammar with parse/serialize round-trip, a lossless+idempotent user-profile fold (source-absent + populated), and a deterministic entity-id + provenance module. All unit-tested, TDD (failing test first). Gate stays green.

2. Package layout (scripts/brain/) — exact copy of scripts/specifics-bank/ conventions

scripts/brain/
  package.json          # copy specifics-bank/package.json; "test": "node --import tsx --test tests/*.test.ts"; devDeps tsx + typescript ONLY (no yaml — see §3.3)
  tsconfig.json         # copied VERBATIM from scripts/specifics-bank/tsconfig.json (rootDir ./src, include src/**/*)
  src/
    types.ts            # Provenance, FactStatus, ProfileFact, ProfileDoc, constants
    id.ts               # mintEntityId(), slugify(), normalizeProvenance()
    profile.ts          # parseProfile(), serializeProfile(), foldUserProfile()
    dataRoot.ts         # inline resolver (repo idiom — §3.5)
    cli.ts              # `brain init`
  tests/                # NOTE: tests/*.test.ts, run via tsx; import source via .js specifiers (e.g. "../src/id.js")
    id.test.ts
    profile.test.ts
    scaffold.test.ts
    fold.test.ts
  fixtures/
    user-profile.populated.md   # synthetic populated user-profile mirroring the template's extractable constructs (§3.4)

B1 fix (verified): all three sibling TS packages use tests/*.test.ts via node --import tsx --test tests/*.test.ts and import source through .js specifiers (specifics-bank/package.json:8, specifics-bank/tests/bank.test.ts:15 from "../src/bank.js"). The __tests__/*.test.mjs idiom is the HOOKS convention (no tsx loader) and cannot import .ts source — NOT used here.

3. Module designs

3.1 types.ts

export type Provenance = 'human' | 'published' | 'ai-draft';
export type FactStatus = 'active' | 'superseded';
export interface ProfileFact {
  id: string;            // canonical entity id (id.ts), keyed on a stable slug
  value: string;         // single-line (no embedded newline)
  first_seen: string;    // YYYY-MM-DD
  last_seen: string;     // YYYY-MM-DD
  evidence_count: number;
  provenance: Provenance;
  status: FactStatus;
}
export interface ProfileDoc { schemaVersion: 1; static: ProfileFact[]; dynamic: ProfileFact[]; }
export const PROVENANCE_VALUES = ['human','published','ai-draft'] as const;

3.2 id.ts

  • slugify(label: string): string → lowercase, trim, non-alphanumeric → -, collapse repeats. Stable across value edits.
  • mintEntityId(seed: {kind: string; key: string}): stringsha256(${kind}:${slugify(key)}).slice(0,12) (matches trends/specifics sha256[:12]; confirm the exact hash call against scripts/specifics-bank/src/bank.ts in step 1). Deterministic, pure.
  • normalizeProvenance(raw: string): Provenance → trim+lowercase; return the match or throw on anything outside PROVENANCE_VALUES. (m3: throws — SC4 asserts assert.throws; "normalises" dropped.)

3.3 profile.ts — pinned line-grammar (M2 decision: NO YAML dep)

Verified: the repo has no YAML parser (state-updater.mjs:43 extractField is per-field regex on single scalars; zero yaml dependency in-tree). Frontmatter-with-nested-arrays would force a new dep + an untested hand-rolled emitter. Decision — a defined line-grammar that round-trips with the regex idiom and needs no dep:

# Profile

schemaVersion: 1

## Static

- [<provenance>|<status>|<first_seen>|<last_seen>|<evidence_count>|<id>] <value>

## Dynamic

- [<provenance>|<status>|<first_seen>|<last_seen>|<evidence_count>|<id>] <value>
  • The bracket holds six pipe-joined constrained tokens (two enums, two ISO dates, an int, a 12-hex id) — none can contain ] or |. <value> is the rest of the line after ] (free single-line text; may contain ]/|/quotes).
  • parseProfile(text): split into ## Static / ## Dynamic sections; each - [...] line parsed by ^- \[(human|published|ai-draft)\|(active|superseded)\|(\d{4}-\d{2}-\d{2})\|(\d{4}-\d{2}-\d{2})\|(\d+)\|([0-9a-f]{12})\] (.*)$. schemaVersion read by the existing scalar-regex idiom.
  • serializeProfile(doc): deterministic token order → - [...] value, sections in fixed order.
  • parseProfile(serializeProfile(doc)) === doc over the WHOLE file (C1 resolved: there is no derived/ignored body — the lines ARE the authoritative representation). SC2 tests this identity.

3.4 profile.tsfoldUserProfile() (M4: extraction rule pinned against the real template)

config/user-profile.template.md is heterogeneous (config/user-profile.template.md:15-147: bold-label scalars, a numbered expertise group, checkbox lists, non-labeled bullets, prose guidance). Pinned extraction — exactly two productions:

  • P1 (labeled scalar): any line matching ^\s*(?:- )?\*\*(.+?):\*\*\s*(.*)$label=g1, value=g2 (the [placeholder] text; treated as unfilled/empty when it is a bracketed placeholder, literal otherwise). One ProfileFact per match. Covers Name/Role/Organization/Industry, the - **Primary:** etc. sub-labels, Signature Elements, Writing Quirks, Current LinkedIn Status.
  • P2 (expertise group): under the **Core Expertise Areas (...)**: label, each ^\d+\.\s*(.*)$ line → one fact, label expertise-area-N.
  • Excluded (deferred, noted §8): checkbox lines (- [ ] … — Tone, LinkedIn Goals, Research MCPs, Asset Utilization), non-labeled bullet lists (Content Style Mix), the Voice-Profile-Summary numbered 1. **[Quality N]:** items, prose guidance blocks (Universal anti-patterns 95-104, Research-Tooling explainer), and headers.
foldUserProfile({ templateText, instanceText?, existing? }): ProfileDoc
  1. Apply P1+P2 to templateText → the canonical field-set (id = mintEntityId({kind:'profile-field', key: label}), value empty, provenance:'human', evidence_count:0, status:'active', dates = run date). → static[]. (dynamic[] empty in S0.)
  2. Source-absent (common): no instanceText → return that field-set with empty values.
  3. Populated: instanceText present → re-run P1+P2 on it, copy filled values onto the matching id.
  4. Idempotent: if existing passed → merge by id; never duplicate, never overwrite a non-empty value, bump last_seen only. Pure; the CLI supplies the file texts. (m4: id keyed on slugify(label), stable across value edits; label-text stability is a known S0 limit — §8.)

3.5 dataRoot.ts — inline resolver (M3: repo idiom, NOT a new seam)

Verified: no shared util; cross-package import not configured (specifics-bank/tsconfig.json rootDir ./src, no path map/workspace); siblings each inline it (trends/src/store.ts:180, specifics-bank/src/bank.ts:150, plus analytics/src/utils/storage.ts:54). Match the idiom:

import { homedir } from 'node:os'; import { join } from 'node:path';
export const dataRoot = (sub: string) =>
  join(process.env.LINKEDIN_STUDIO_DATA ?? join(homedir(), '.claude', 'linkedin-studio'), sub);

This is a private package resolver, not an exported seam function → SC6 (no new seam, twin-sync untouched) holds. True de-duplication of the now-4 copies is an explicit out-of-scope refactor.

3.6 scaffold.tsinitBrain(rootSub?) (D1, D5) + cli.ts

  • Create idempotently (mkdir-recursive; compare-then-skip, never blind-write): brain/journal/, ingest/inbox/, ingest/published/; brain/profile.md (IF absent → serializeProfile(foldUserProfile({templateText}))), brain/index.md (IF absent → MOC seed: one line per tributary [voice-samples, specifics-bank, trends, analytics, ingest] with freshness: —), brain/operations.md (IF absent → ## Who I am now (anchor) + empty ## Plans / ## Ideas).
  • Returns {created[], skipped[]}. No session-start wiring (SB-S2). cli.ts: brain initinitBrain(), print report. Dispatch shape copied from specifics-bank/src/cli.ts.

4. Files created / edited

Created: scripts/brain/{package.json,tsconfig.json} · scripts/brain/src/{types,id,profile,dataRoot,cli}.ts · scripts/brain/tests/{id,profile,scaffold,fold}.test.ts · scripts/brain/fixtures/user-profile.populated.md.

Edited (minimal):

  • scripts/test-runner.sh — add a BRAIN_TESTS_FLOOR block copied from the TRENDS_TESTS_FLOOR block (scripts/test-runner.sh:681-694): cd scripts/brain, run the suite, grep tests N (grep -oE 'tests [0-9]+' | tail -1), assert ≥ BRAIN_TESTS_FLOOR, emit one pass() line. Bump Section 18's anti-erosion note (:770, TOTAL_CHECKS >= 74 grows by 1).
  • docs/second-brain/architecture.md — flip SB-S0 row to "landed" (DoD).
  • (NO change to data-root.mjs/storage.ts — no seam function added, SC6.)

5. TDD test plan (failing-first) → success criteria

Test (tests/*.test.ts) Asserts SC
id.test.ts mintEntityId deterministic (same in→same id; diff→diff); slugify stable; normalizeProvenance returns the 3 values, assert.throws on others SC4
profile.test.ts parseProfile(serializeProfile(doc)) === doc over the whole file incl. a value containing ]/` /quotes; sections ## Static/## Dynamic` present
fold.test.ts (a) source-absent → field-set from template (P1+P2), values empty; (b) populated fixture → every filled field carried (diff none-dropped); (c) re-run → no dup, no overwrite SC3
scaffold.test.ts LINKEDIN_STUDIO_DATA=tmp → init creates all dirs+files at runtime path; second init = no-op (skipped[] covers them, files byte-identical) SC1
(gate) scripts/test-runner.sh green; trends ≥24, specifics ≥28, contract ≥33, brain ≥ floor; hooks pass SC5
(n/a) no new seam fn; unchanged data-root.test.mjs twin-sync still passes SC6

Each test written before its module (iron law): red → implement → green.

6. Step sequence

  1. Confirm idioms in code (verify-first): read scripts/specifics-bank/{package.json,tsconfig.json,src/bank.ts,tests/bank.test.ts} → copy the package/test scaffolding + the exact sha256 call. npm install in scripts/brain so tsx is present (m2: the gate warn-skips a suite if node_modules/.bin/tsx is absent — "green" must not mean green-because-skipped).
  2. types.ts (no logic).
  3. TDD id.ts (id.test.ts). → SC4.
  4. TDD profile.ts parse/serialize line-grammar (profile.test.ts). → SC2.
  5. Write fixtures/user-profile.populated.md mirroring the P1+P2 constructs; TDD foldUserProfile (fold.test.ts, 3 cases). → SC3.
  6. TDD scaffold.ts initBrain (scaffold.test.ts, temp-dir idempotency). → SC1.
  7. cli.ts brain init; smoke-run against a temp LINKEDIN_STUDIO_DATA.
  8. Wire scripts/test-runner.sh BRAIN floor (+ Section 18 note); run the FULL gate green. → SC5/SC6.
  9. Flip architecture.md SB-S0 → landed. Commit; confirm before push (PUBLIC open/ Forgejo) and only inside the push window; STATE.md stays gitignored (G1).

7. Scope fence (echo — SB-S0 does NOT)

No consolidation loop · no ingest file parsing · no LinkedIn API · no id threading into tributaries · no session-start wiring · no tributary-schema edits · no state-file/two-roots reconciliation · no content-history.md retirement · no GUI.

8. Known limits / deferred (honest)

  • Fold covers only P1 labeled-scalars + P2 expertise group. Checkbox preference lists (tone, goals, MCPs, assets), non-labeled bullets, and the Voice-Profile-Summary qualities are NOT folded in S0 — they are template guidance/preferences, foldable in a later slice when the profile schema grows. Stated so SC3a's field-set is finite + testable.
  • Profile-field id keyed on slugify(label) — stable across value edits (fixes the raw-label non-idempotency). A renamed template label would still mint a new id; acceptable in S0 (template is fixed) — re-confirm if SB-S1+ lets users edit labels.
  • parse∘serialize identity holds for single-line values. A value with an embedded newline is out of grammar; the fold never produces one (template/instance fields are single-line), but addFact paths in later slices must enforce it.
  • Gate "green" is honest only with deps installed (m2) — fresh clone must npm install scripts/brain or the brain suite warn-skips.

9. Risks

  • Fold under-specification → mitigated: P1+P2 pinned + fold.test.ts covers source-absent + populated + re-run before the CLI wires it.
  • Idempotency edge: initBrain + fold merge must compare-then-skip (never blind-write); tested in scaffold.test.ts / fold.test.ts (c).
  • Gate mechanics (corrected, m1): 89 is the gate's own PASS+FAIL tally; each suite contributes exactly ONE pass() line (not its internal tests N); the only total guard is the growable TOTAL_CHECKS >= 74 floor (:770). Adding BRAIN raises that floor by 1 — no ==89 guard exists, so no regression flag. The risk is the warn-skip (m2), handled in step 1/8.