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

162 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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
- [<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.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.