# 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` ```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}): string` → `sha256(`${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 - [|||||] ## Dynamic - [|||||] ``` - The bracket holds six pipe-joined **constrained** tokens (two enums, two ISO dates, an int, a 12-hex id) — none can contain `]` or `|`. `` 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.ts` — `foldUserProfile()` (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: ```ts 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.ts` — `initBrain(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 init` → `initBrain()`, 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 | SC2 | | `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.