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
14 KiB
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 — thebrain/scaffold, the two-layerprofile.md+ lossless user-profile fold, the entity-id/provenance module, theingest/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.tsvia tsx,.jsspecifiers); 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}): string→sha256(${kind}:${slugify(key)}).slice(0,12)(matches trends/specificssha256[:12]; confirm the exact hash call againstscripts/specifics-bank/src/bank.tsin step 1). Deterministic, pure.normalizeProvenance(raw: string): Provenance→ trim+lowercase; return the match or throw on anything outsidePROVENANCE_VALUES. (m3: throws — SC4 assertsassert.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/## Dynamicsections; 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})\] (.*)$.schemaVersionread by the existing scalar-regex idiom.serializeProfile(doc): deterministic token order →- [...] value, sections in fixed order.parseProfile(serializeProfile(doc)) === docover 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). OneProfileFactper 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, labelexpertise-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 numbered1. **[Quality N]:**items, prose guidance blocks (Universal anti-patterns 95-104, Research-Tooling explainer), and headers.
foldUserProfile({ templateText, instanceText?, existing? }): ProfileDoc
- 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.) - Source-absent (common): no
instanceText→ return that field-set with empty values. - Populated:
instanceTextpresent → re-run P1+P2 on it, copy filled values onto the matching id. - Idempotent: if
existingpassed → merge byid; never duplicate, never overwrite a non-empty value, bumplast_seenonly. Pure; the CLI supplies the file texts. (m4: id keyed onslugify(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.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] withfreshness: —),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 fromspecifics-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 aBRAIN_TESTS_FLOORblock copied from theTRENDS_TESTS_FLOORblock (scripts/test-runner.sh:681-694): cdscripts/brain, run the suite, grepℹ tests N(grep -oE 'tests [0-9]+' | tail -1), assert≥ BRAIN_TESTS_FLOOR, emit onepass()line. Bump Section 18's anti-erosion note (:770,TOTAL_CHECKS >= 74grows 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
- 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 installinscripts/brainso tsx is present (m2: the gatewarn-skips a suite ifnode_modules/.bin/tsxis absent — "green" must not mean green-because-skipped). types.ts(no logic).- TDD
id.ts(id.test.ts). → SC4. - TDD
profile.tsparse/serialize line-grammar (profile.test.ts). → SC2. - Write
fixtures/user-profile.populated.mdmirroring the P1+P2 constructs; TDDfoldUserProfile(fold.test.ts, 3 cases). → SC3. - TDD
scaffold.tsinitBrain(scaffold.test.ts, temp-dir idempotency). → SC1. cli.tsbrain init; smoke-run against a tempLINKEDIN_STUDIO_DATA.- Wire
scripts/test-runner.shBRAIN floor (+ Section 18 note); run the FULL gate green. → SC5/SC6. - Flip
architecture.mdSB-S0 → landed. Commit; confirm before push (PUBLICopen/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∘serializeidentity 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), butaddFactpaths in later slices must enforce it.- Gate "green" is honest only with deps installed (m2) — fresh clone must
npm install scripts/brainor the brain suitewarn-skips.
9. Risks
- Fold under-specification → mitigated: P1+P2 pinned +
fold.test.tscovers source-absent + populated + re-run before the CLI wires it. - Idempotency edge:
initBrain+ fold merge must compare-then-skip (never blind-write); tested inscaffold.test.ts/fold.test.ts(c). - Gate mechanics (corrected, m1): 89 is the gate's own
PASS+FAILtally; each suite contributes exactly ONEpass()line (not its internaltests N); the only total guard is the growableTOTAL_CHECKS >= 74floor (:770). Adding BRAIN raises that floor by 1 — no==89guard exists, so no regression flag. The risk is the warn-skip (m2), handled in step 1/8.