linkedin-studio/docs/second-brain/brief-sb-s3e.md
Kjell Tore Guttormsen fbad29d3d4 docs(linkedin-studio): SB-S3e brief + plan — hygiene + triple-post reconciliation (light-Voyage hardened)
The LAST S3 slice: (b) retire the dead, zero-reader content-history.md across
its 8 plumbing surfaces; (c) a read-side `brain reconcile` joining silo 1
(## Recent Posts, auto-tracked creation) to the silo 2↔3 graph — surfacing the
coverage gap (posts created but never `brain ingest`-ed), without writing the
state silo.

Light-Voyage hardened: scope-guardian ALIGNED (0/0/0); brief-reviewer REVISE
(4 FIX) + plan-critic REVISE (3 blockers + 6 major + 4 minor), all folded. The
three blockers were real factual errors against the code, each verified directly
before folding: listPublished() is body-less (use runAssemble's inline
parsePublishedRecord loader); the core signature must be {recentPosts, records,
graph} (PostGraphNode has no body); the parser tracks the WRITER format
(state-updater.mjs:116), not pruneContentHistory's date-only :145 regex. Plus the
getStateFile() HOME-chain seam, logic-RED, single-literal gate decoys, recorded
gate TOTAL 97.

Go-before-code gate pending (forks: retire vs back-fill; read-side vs write-side).

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

21 KiB
Raw Permalink Blame History

Brief — SB-S3e: hygiene + triple-post reconciliation (the LAST S3 slice)

Slice: SB-S3e — the focused finale of SB-S3. Carries the two coupled deliverables the S3d split deferred: (b) retire the dead content-history.md + (c) triple-post reconciliation (silo 1 post-tracking ↔ silo 2 brain published-records ↔ silo 3 analytics). After this slice the second-brain arc is complete (S4 EØS DMA-connector is optional). Status: DRAFT (brief + plan written; light-Voyage hardening + go-before-code gate pending). Predecessors: SB-S0 (scaffold seeds the bare tree) · SB-S1 (ingest/published/ gold-signal records = silo 2) · SB-S3c (assemble.ts built the silo 2↔3 join; deliberately did NOT touch silo 1) · SB-S3d (the ops-centre reader — independent).

1. Operator decision context (2026-06-23)

The original S3d charter bundled three deliverables; the S3d brief (§1) found a clean fault line and split: (a) the ops centre shipped as S3d (additive, no silo contact); (b) + (c) are coupled and move here, to S3e. They are coupled because the content-history back-fill question is the reconciliation question: content-history.md was a second auto-written record of created posts, so deciding its fate (retire vs resurrect) and deciding how the auto-tracked creation stream reconciles to the brain graph are the same decision. Both touch the loose, regex-mutated post-tracking silo that S3c deliberately avoided. S3e is the deliberate, isolated finale for that surgery.

2. The gap — grounded in code

A "post created via the plugin" leaves three separate records, and they do not agree:

# Silo Path / writer Carries Join key?
1 post-tracking ## Recent Posts in $HOME/.claude/linkedin-studio.local.md — auto-written by the Stop-hook → updatePostTracking (state-updater.mjs:114-126) [date] "hook (≤60c preview)" (charCount) - topic none
2 brain published-record ingest/published/<contentId>.md under the brain dataRoot — written only by manual brain ingest --file … (cli.ts:122-138) contentId, published_date, specifics[], trends[], full body contentId (internal)
3 analytics analytics/posts/*.json under the brain dataRoot — from the LinkedIn CSV import title, publishedDate, metrics none (no body, no URN)

S3c built the silo 2↔3 join (assemble.ts: assemblePostGraph) by a confidence-tiered title-prefix + date heuristic, answering the arc's north-star query "which raw material actually performs?" (specifics → post → measured analytics).

Two gaps remain, and they are the same coin:

  • Silo 1 is joined to nothing. The complete auto-captured stream of created posts (silo 1) never meets the graph. The graph (silo 2→3) sees only the subset the user manually ran brain ingest on — so the "which raw material performs" payoff is blind to every post the user created but never hand-ingested. Verified: silo 2 has no auto-captureassemble's empty-state message literally instructs the user to brain ingest --file … manually (cli.ts:182); STATE lists auto-capture as the explicit optional S3c-bonus, i.e. it does not exist.
  • The dead content-history.md is a fourth, genuinely-orphaned record (date/hook/topic/format/words/chars/source), written by flaky Stop-hook prose (state-update-reminder.md:67-86) and read by nobody (verified: the only content-history mentions in code are a migrate-data.mjs MOVE entry, its test, the .gitignore/SC2 data-class guards, and a session-start.mjs comment — zero readers). It is redundant with silo 1 (both auto-written, both unread by the graph) and resurrecting it would solve nothing the join needs. Honesty nuance (the prose lies): the writer prose being deleted claims the log "enables /linkedin:report and analytics-interpreter to track content production" (state-update-reminder.md:85) — but that was never wired (grep-verified: no command/agent reads the file; the profile.md/analyze.md/audit.md "content history" hits are the conceptual "have you posted on this topic" signal, not file reads). The claim is aspirational dead prose, which strengthens retirement — but it must be named so a mid-deletion reader doesn't chase a phantom reader.

3. Scope — what is IN (S3e)

(b) Retire the dead content-history.md — clean deletion across its plumbing

Verified surfaces (grounded this session):

  1. Writer prose — remove Section 5 "Content History Log" from hooks/prompts/state-update-reminder.md:67-86 (the flaky generator). This is the only thing that ever wrote the file.
  2. Template — delete config/content-history.template.md.
  3. Migration — remove the B1 entry from MOVE_FILES in hooks/scripts/migrate-data.mjs:33 (['assets/analytics/content-history.md', 'analytics/content-history.md']).
  4. Migration test — drop the content-history fixture write (__tests__/migrate-data.test.mjs:37), its two assertions (:56, :61), and trim the test-(a) description (:50). Test count unchanged (5 cases ae stay) → hook-suite total unchanged.
  5. .gitignore:44 — remove assets/analytics/content-history.md.
  6. Gate guard — remove the assets/analytics/content-history\.md alternative from SC2_CLASSES (test-runner.sh:592). The SC2 check stays (one pass/fail) → gate total unchanged.
  7. Reference doc — remove content-history.md from the data-tree diagram (references/data-path-convention.md:26).
  8. Comment — drop content-history from the session-start.mjs:94 comment list.

NOT in scope of (b): state-updater.mjs's pruneContentHistory (despite the name, it operates on the live ## Recent Posts in the state file — :138-172 — and is untouched), and any conceptual "content history" prose (e.g. README:120 /linkedin:profile "content-history alignment" reads as the concept, not the file — verify-then-leave; never delete a conceptual mention).

(c) Triple-post reconciliation — read-side joiner (recommended fork)

Extend the brain's read-only assembler so silo 1 is reconciled against the graph, without writing the state file (faithful to S3c's "read, never write").

  1. A new pure reconcile core (scripts/brain/src/reconcile.ts): reconcileRecentPosts({ recentPosts, records, graph }) — takes the parsed silo-1 entries + the full PublishedRecord[] (which carry body) + the existing PostGraphNode[], and returns, per silo-1 entry, whether it is present in the brain (a matched published record) and/or analytics, with an explicit confidence tier — surfacing the coverage gap: created posts that never reached the brain graph. Why records and not graph-only: PostGraphNode carries contentId + analytics match but no body (assemble.ts:48-54), so the hook→post prefix-match needs the PublishedRecord.body; the core matches hook → record.body, then record.id → graph node for the analytics tier. No FS/clock/network in the core (the assemble.ts discipline).
  2. A thin read-only state-file parser (parseRecentPosts(stateText)): extract [date] "hook" (chars) - topic rows from the ## Recent Posts section. The format source of truth is the writer updatePostTracking (state-updater.mjs:116: - [date] "hook" (chars) - topic) — not pruneContentHistory's regex, which is date-only (:145, /^- \[(\d{4}-\d{2}-\d{2})\] .+$/gm) and captures no hook/chars/topic. parseRecentPosts is the first full-field reader of this section; it shares only the date-anchor shape with the pruner. A golden-string test pins a writer-produced entry round-tripping through parseRecentPosts.
  3. A new brain IO seam for the state file. Silo 1 lives at STATE_FILE || $HOME/.claude/linkedin-studio.local.mdoutside the brain dataRoot (LINKEDIN_STUDIO_DATA). A new loadRecentPosts() IO resolves that path with the canonical precedence already exported as data-root.mjs: getStateFile()STATE_FILE first, else join(resolveHome(), '.claude', 'linkedin-studio.local.md') where resolveHome() = HOME || USERPROFILE || homedir() (NOT bare homedir() — that would ignore a HOME/USERPROFILE override the writer honours). Reads read-only; absent → [] (fresh-clone safe). This is a genuinely new root-skew caveat (the state file resolves via STATE_FILE, a different root than the brain dataRoot — not a mirror of assemble.ts's ANALYTICS_ROOT note, which is about the analytics dir under LINKEDIN_STUDIO_DATA). (A hook-side .mjs reconciler is pathguard-blocked — new .mjs under hooks/scripts/ is forbidden — so the brain TS side is the only clean home; it copies getStateFile()'s chain since it cannot import the .mjs.)
  4. A CLI subcommand brain reconcile (cli.ts): loads silo 1 + the assembled graph, prints the per-post coverage report (in brain / in analytics / orphaned-in-state), and a one-line summary ("N created posts, M in the brain graph, K un-ingested → brain ingest them to feed the graph"). Read-only.
  5. Deterministic gate section (new Section, modelled on 16e/16d): unconditional checks that the reconcile wiring exists (CLI subcommand registered; core exported). ASSERT_BASELINE_FLOOR bumped by exactly the new unconditional checks.
  6. Brain-suite tests (RED first): reconcile.test.ts — coverage tiers (present/absent/orphaned), the truncated-hook prefix limit, empty-state degradation. BRAIN_TESTS_FLOOR bumped by the new passing count (recorded live, not assumed).

Honest limits of read-side (documented, not hidden): (i) silo 1 carries only a ≤60-char truncated hook preview, a weaker prefix signal than silo 2's full body → lower match confidence, reported honestly as a tier, never as proof. (ii) Read-side cannot retroactively assign the specifics/trends ids a post was built from — those were never captured for un-ingested posts; the join can only say "this created post is missing from the graph," not reconstruct its raw material. (iii) The true gap-closer is auto-capture at drafting (silo-2 record minted with specifics/trends at creation) — explicitly a follow-up, not S3e (STATE's S3c-bonus). S3e ships the honest read-side reconciliation + the coverage signal that motivates auto-capture next.

4. Non-goals — what is OUT (deferred)

  • Write-side stamping — embedding a contentId into the ## Recent Posts entry. Rejected for S3e: it requires a contentId to exist at post-creation, i.e. auto-capture (out of scope), and it mutates the $-injection-defended regex silo (updatePostTracking already had to use replacement functions to stay $-safe — state-updater.mjs:117-125). Heavier + riskier + premature. See §8 fork 2.
  • Auto-capture at drafting (the real gap-closer) — minting a silo-2 published record with specifics/trends ids at post-creation. The motivated follow-up; S3e's coverage report is what makes its value legible.
  • Resurrecting content-history.md as a live read tributary (the back-fill arm of fork 1) — it would add a second weak auto-record the graph still can't join. Rejected; retirement chosen.
  • Any change to pruneContentHistory / ## Recent Posts writer / state-updater.mjs — S3e reads silo 1, never writes it.
  • A second reconcile consumer (a command/agent that acts on the coverage report) — S3e ships the brain reconcile view; wiring an agent to nudge "ingest these" is follow-on.
  • assemble.ts join-heuristic changes — reconcile consumes the existing graph; it does not re-tune the silo 2↔3 matcher.

5. Boundaries / invariants (must hold)

  • READ-only into the state silo — S3e adds no write path to ## Recent Posts or any state-file field; state-updater.mjs stays the sole writer of silo 1.
  • READ-only into generationbrain consolidate --apply --confirm stays the sole writer of profile.md; reconcile writes nothing.
  • Retirement is plumbing-only — (b) removes a dead file + its writer/migration/guards; it must not touch the live ## Recent Posts, pruneContentHistory, or any conceptual "content history" prose.
  • No new hook .mjs — pathguard forbids new .mjs under hooks/scripts/; reconcile lives in scripts/brain/ (TS). Editing the existing migrate-data.mjs (a deletion) is an Edit of an existing file → allowed.
  • State-file root precedence mirrored exactlyloadRecentPosts() resolves STATE_FILE || $HOME/.claude/linkedin-studio.local.md, identical to the .mjs side; a divergence would silently read the wrong file.
  • Fresh-clone / empty-state safe — missing state file or empty ## Recent Posts → reconcile degrades to a clean empty report, never a crash (mirrors loadAnalyticsRows).
  • Migration idempotency intact — removing the B1 MOVE entry must not break migrate-data's .migrated-marker idempotency or the COPY/MOVE classes; the remaining test cases (be) stay green.
  • TDD iron law — the failing reconcile test (and the failing gate-section self-test) land BEFORE the reconcile code.

6. Success criteria (testable)

  • SC1 — content-history retired: grep -rn "content-history" hooks/ config/ scripts/test-runner.sh .gitignore references/data-path-convention.md (NO --include filter — it must reach .gitignore and the .md ref-doc, which an extension-filter would silently skip) returns zero (file, writer, MOVE entry, gitignore line, SC2 alternative, ref-doc, comment all gone); the template file is deleted. The README :120 conceptual mention is intentionally outside this grep set (concept, not file). (grep + ls assertion — identical command in the plan, SC1)
  • SC2 — migration green after retirement: (cd … && node --test hooks/scripts/__tests__/migrate-data.test.mjs) passes with cases ae; no content-history assertion remains. (hook suite)
  • SC3 — reconcile core correct (RED-first, LOGIC-RED not import-RED): reconcile.test.ts asserts the three coverage tiers (in-graph / in-brain-only / orphaned-in-state), truncated-hook degradation, and empty-state → empty report. RED proof recorded: the artifact must show the named tier assertions failing (e.g. "expected orphaned-in-state for the un-ingested entry") against a stub reconcile.ts that compiles but mis-classifies (returns [] / wrong tiers) — a bare MODULE_NOT_FOUND import-RED proves nothing about the assertion logic and is rejected as vacuous. BRAIN_TESTS_FLOOR bumped to the recorded new count. (brain suite)
  • SC4 — reconcile wired: brain reconcile runs end-to-end on a seeded fixture (silo 1 + published + analytics) and prints the coverage report + summary; a new gate Section asserts the CLI subcommand + core export exist (unconditional self-test + real-file grep). (lint + manual run)
  • SC5 — gate green + floor honoured: scripts/test-runner.sh stays green; TOTAL recounted live at land (current 97; + the new unconditional checks). ASSERT_BASELINE_FLOOR bumped by exactly that many; SC2-dry-run still passes with the narrowed SC2_CLASSES.
  • SC6 — root-skew (manual/behavioural — EXPECTED to land as a real pass): with STATE_FILE pointing at a temp state file, brain reconcile reads silo 1 from it; with it unset it falls back to $HOME/.claude/linkedin-studio.local.md. This is a trivial STATE_FILE env toggle in a temp dir — exercisable in the same shell that runs the gate — so the default expected landing state is a real behavioural pass, not a hedge. Fallback (verifiseringsplikt): only if the env toggle genuinely cannot run, record wiring-inspected-only, NOT a behavioural pass — the hedge must never become a standing excuse to skip a runnable check.
  • SC7 — counts reconciled: architecture.md S3 build-row marked S3e (arc complete); STATE telling updated (gate total, ASSERT floor, brain floor); no CLAUDE.md command/agent/reference-doc count change (no new command/agent; reconcile.ts is a brain module + CLI subcommand, not a plugin surface — confirm at the gate whether brain reconcile warrants a one-line CLAUDE.md mention).

7. Verification

  • Deterministic (gate): SC1/SC4/SC5 via grep + the new gate Section in test-runner.sh; SC2 via the migrate-data hook test; SC3 via the brain suite (cd scripts/brain && npm install before the gate, else warn-skip — STATE rule).
  • Behavioural (manual, documented — the honest limit): SC4 end-to-end run + SC6 root-skew — recorded in STATE/changelog at land; never overclaim a behavioural pass not run (verifiseringsplikt).

8. Open questions for brief-review / the go-gate

  1. Fork 1 — content-history: retire vs back-fill. Recommended retire — zero readers, redundant with silo 1, and back-fill resurrects a second weak auto-record the graph still cannot join. Confirm.
  2. Fork 2 — reconciliation: read-side joiner vs write-side stamp. Recommended read-side — faithful to S3c's read-only invariant, avoids the $-injection regex silo, and write-side is blocked on auto-capture (no contentId exists at creation today) so it would be premature. Cost honestly stated: a new cross-seam read (brain TS → state file). Confirm; write-side + auto-capture become the flagged follow-up.
  3. Scope of (c): build the read-side joiner now, or document-the-design + defer the build? Recommended build now — the coverage report is the genuine deliverable that closes the arc and motivates auto-capture; a doc-only finale under-delivers the last slice. Confirm the build.
  4. CLAUDE.md surface for brain reconcile. The brain CLI is an internal tool, not a plugin command — recommended: a one-line architecture note, no command-count change. Confirm.

9. Light-Voyage review — folded

Three reviewers ran on the brief + plan (2026-06-23):

  • voyage:scope-guardian: ALIGNED — 0 creep, 0 gaps, 0 dependency issues. All 14 IN items (b1b8, c1c6) map to a plan step; no NON-GOAL file is touched (the scope fence is consistent with the steps, and slightly broader/defensive); doc reconciliation is proportional (architecture build-row + STATE telling only). Every referenced file/line/symbol verified against the live codebase.
  • voyage:brief-reviewer: REVISE → 4 [FIX]es folded:
    • FIX-1 (load-bearing): the parser must mirror the writer updatePostTracking (state-updater.mjs:116), NOT pruneContentHistory — whose :145 regex is date-only and captures no hook/chars/topic. Folded into §3(c).2 (+ a golden-string round-trip test). The "they never disagree" claim was rebuilt on the writer-format source of truth.
    • FIX-2 (honesty): the writer prose being deleted claims /linkedin:report + analytics-interpreter as readers, but they were never wired — aspirational dead prose. Named in §2 so the "zero readers" verdict is auditable, not asserted.
    • FIX-3 (testability): SC3 RED tightened to a logic-RED (named tier assertions fail against a mis-classifying stub), rejecting a vacuous import-RED.
    • FIX-4 (testability + consistency): SC6 pre-committed as an expected real behavioural pass (trivial env toggle), wiring-only as documented fallback; and the root-skew caveat is genuinely new (state file via STATE_FILE), not a mirror of assemble.ts's ANALYTICS_ROOT note — cite data-root.mjs: getStateFile() as the canonical precedence. Folded into §3(c).3 + SC6.
    • Also folded: the core signature corrected to { recentPosts, records, graph } (graph-only cannot prefix-match — PostGraphNode has no body), and SC1's grep unified with the plan's (no --include filter, which would skip .gitignore/.md).
  • voyage:plan-critic: REVISE (3 blockers + 6 major + 4 minor) — folded into plan-sb-s3e.md §"Plan-critic — folded". The three blockers were real factual errors against the code, each verified directly before folding: (B1) listPublished() is body-less → use runAssemble's inline parsePublishedRecord loader (cli.ts:162-176, which already builds a bodyById Map); (B2) the core signature contradiction (= the brief's signature fold); (B3) the :145-regex misattribution (= FIX-1). Majors: the getStateFile() HOME-chain (not bare homedir()), single-literal gate decoys, Check-B literal-name constraint, recorded gate TOTAL (verified live 97), demonstrated migrate idempotency, and the SC1 grep unification.

Net: scope unchanged (ALIGNED); the folds correct three load-bearing factual errors in the reconcile design (body-less loader, wrong regex, wrong HOME seam) + harden the RED guarantee and the gate's non-vacuity. The retirement arm (b) was already nearly executable; the reconcile arm (c) is now grounded in the actual assemble.ts/ingest.ts/cli.ts shapes.