Slice SB-S2 (Evolution loop): operator-invoked consolidation engine → profile diff (threshold-promotion N=3, conflict keep-both with distinct ids, decay-flag 90d, provenance-gated) + brain/consolidation-state.json sidecar + zero-dep session-start consolidation-due nudge + scaffold-ensure. Light-Voyage-hardened: brief-reviewer (REVISE→folded: state-file→sidecar fix), plan-critic (REPLAN 58→folded: distinct-id model, gather reads bodies, hook-tests-run-separately), scope-guardian (ALIGNED). Operator scope: journal deferred · deterministic CLI no-agent · motor-only no-reader. Defaults N=3/90d, version → 0.5.2. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RigJBiRFNtFZKCz21qNbQ4
20 KiB
Task Brief — SB-S2 (Evolution loop)
Status: DRAFT for adversarial brief-review (light-Voyage step 2). Not yet operator-approved. Arc:
architecture.md(approved 2026-06-23). Predecessors: SB-S0 (scaffold + profile spine) + SB-S1 (ingest + published gold signal), both landed + released (v0.5.1). Operator scope decisions (2026-06-23, settled before this brief): (1) journal-capture DEFERRED — S2 consumespublished+ tributary deltas only; (2) deterministic CLI engine + the invoking session does the fuzzy extraction (count-neutral, NO new agent); (3) motor-only — S2 evolvesbrain/profile.mdbut wiring a reader is S3 (accepted, flagged). Inputs:architecture.md§"evolution loop" + §"invariants"; SB-S0/S1 code (scripts/brain/); the session-start surface (hooks/scripts/session-start.mjs). Review delta (2026-06-23): folded the brief-review (REVISE). B1+B2 (the state-file write idiom is false AND the brain CLI can't reach the HOME-scoped state file) → resolved by storing consolidation state in a brain data-root sidecar (brain/consolidation-state.json), reachable by both--apply(dataRoot) and the session-start nudge (getDataRoot) — no state-file involvement. M3--proposeemits TWO artifacts (md + json). M4 supersede dropped from S2 (keep-both only; supersede→S3). M5 SC1 split per-rule. Minors: SC3 split, candidate-file schema + gate-validation, freshness nudge = new-published-count only (no profile.md parse at session-start), consolidation matcheskind:'observed'only (foldedprofile-fieldstatic seeds immutable in S2).
1. Objective
Build the compounding mechanism: an operator-invoked, sleep-time consolidation pass that reads the new published gold signal + tributary deltas, proposes a diff to the two-layer brain/profile.md (never a silent overwrite) — with evidence-threshold promotion, contradiction reconciliation (bias: keep-both-timestamped), provenance-gated learning (published/human only, never ai-draft), and temporal decay flagging (in the diff, not at session-start) — plus a "consolidation-due" nudge on the session-start surface (new-published count + days-since-last-run; per-fact stale nudging deferred), and the session-start wiring of the scaffold init. This is the loop that turns the gold signal (SB-S1) into an ever-improving, drift-resistant profile.
2. Background (why this shape, given the surfaces)
- The loop cannot run AI at session-start.
session-start.mjsis zero-dep, must NOT spawntsx, 10s timeout (the B-S3 rule). So the consolidation pass (which needs fuzzy fact-extraction) is operator-invoked ("sleep-time" = when the operator runs it), and session-start only emits a deterministic nudge ("consolidation due / N new published posts"). This is faithful to the architecture's "sleep-time consolidation, not inline cost." - journal/ has no producer. SB-S0 created the empty dir; nothing writes episodes. Per the operator decision, S2 consumes
published(SB-S1, structured, provenance-tagged) + tributary deltas (specifics/trends/analytics — all have real timestamps); journal-capture is its own later sub-slice. - The fuzzy extraction lives in the command/session layer, not a new agent (the specifics-bank precedent: the deterministic store + CLI ship, the elicitation interview lives upstream). So S2 ships a deterministic engine + CLI; the invoking session reads the gathered deltas and produces candidate facts. Count-neutral (no
agents/file → no reload, no count-lint bump). - profile.md has no reader yet. S2 evolves it into a (currently) unconsumed artifact; wiring content consumers is S3. Accepted: the value is that the profile compounds NOW so S3's reader inherits rich data. Flagged honestly (§9).
- The diff is operator-gated — matches the longform
SendUserFile+[OPERATØR]idiom (editorial-review): proposed, presentable, never silently applied; confirm/edit/roll-back via git. Anti-sycophancy: the profile is evidence to test, presented neutrally.
3. Confirmed decisions (inherited arc + operator scope — not for re-litigation)
- Boundary: engine → plugin · user data → data dir · cockpit → Maskinrommet.
- Invariants (architecture §"Invariants"): provenance-weighted learning (
published/humanonly, neverai-draft); episodic/semantic split; evidence-threshold promotion; contradiction → keep-both-timestamped; temporal-validity + freshness nudge; anti-sycophancy default; zero required curation. - Operator scope (§Status): journal deferred · deterministic CLI + session extraction (no new agent) · motor-only (no reader until S3).
- No new seam function — brain code uses the package's own
dataRoot; the session-start edit uses the existinggetDataRoot(data-root.mjs). - No AI at session-start (zero-dep nudge only); the consolidation pass is operator-invoked; no auto-apply of any diff.
Review SHOULD challenge: feasibility, the engine's classification rules (§5.1), the session-start edit's zero-dep safety, the diff/apply data shapes, hidden coupling, testability, scope leak, missing risks. Review SHOULD NOT re-open the §3 decisions unless internally contradictory or technically impossible.
4. Scope — where SB-S2 sits in the arc
| Slice | One-line scope | This brief |
|---|---|---|
| SB-S0 — Foundation | scaffold + profile fold + id/provenance spine | ✅ landed |
| SB-S1 — Ingest + gold signal | manual import → ingest/published/; voice-trainer published-only |
✅ landed (v0.5.1) |
| SB-S2 — Evolution loop | consolidation engine → profile diff (threshold/contradiction/decay, provenance-gated) + session-start freshness/consolidation-due nudge + scaffold-ensure wiring | ← this |
| SB-S3 — Cross-silo graph + ops centre | thread the id through tributaries; wire profile.md READERS; flesh out operations.md; retire dead content-history.md |
later |
| SB-S4 (optional) | EU/EEA DMA portability auto-tributary | later |
5. SB-S2 in detail (the build target)
5.1 Deliverables
-
Deterministic consolidation engine (
scripts/brain/src/consolidate.ts) — PURE functions, no IO, heavily tested. Core:proposeDiff({ current: ProfileDoc, candidates: Candidate[], today, opts }) → ProfileDiff. ACandidate={ key, value, provenance, source, observed_date }. Matching: every candidate is keyedmintEntityId({kind:'observed', key})and matched ONLY against existingobserved-kind facts. The SB-S0 foldedprofile-fieldstatic seeds are immutable in S2 — a candidate that semantically overlaps a folded field becomes its ownobservedfact (the keep-both philosophy; reconciling observed-vs-folded is operator-gated / S3). This avoids theprofile-field↔observedid mismatch (fixes brief-review NICE). Classification rules:- Reject any
provenance: 'ai-draft'candidate outright (model-collapse guard) — it never enters or bumps a fact. - Add (no matching
observedfact, provenance ∈ {published, human}) → a new dynamic fact,evidence_count: 1, dates =observed_date/today. - Evidence-bump (matching fact, SAME value) →
evidence_count++,last_seen = today(no other field touched). - Promote (a dynamic fact whose
evidence_countreaches thresholdN, default 3) → move dynamic→static. - Conflict (matching key, DIFFERENT value) → keep-both-timestamped: add the new value as its own fact, keep the old fact untouched (no evidence-bump of the old), and record the pair in
conflicts[]— the architecture's explicit bias against silent overwrite. No supersede in S2 (flippingstatus:supersededis deferred to S3; S2 never demotes/supersedes a fact). - Decay-flag — dynamic facts whose
last_seenis older thanDECAY_DAYS(default 90) are listed instaleFlags[](information only; S2 never auto-demotes).ProfileDiff={ additions[], evidenceBumps[], promotions[], conflicts[], staleFlags[] }(typed; NOsupersessions[]— that arm is S3). The engine NEVER mutatesprofile.md— it returns proposals over a deep-frozen-safe input.applyDiff(current, diff) → ProfileDocis a separate pure function (used only by the gated apply path); its output round-trips throughparseProfile/serializeProfile.
- Reject any
-
brain consolidateCLI (extendscripts/brain/src/cli.ts) — the operator-invoked loop:brain consolidate --gather [--json]— read-only: gather the deltas since the last run (newpublishedrecords + each tributary's newest-timestamp summary + the current profile) and print them, for the invoking session to read and turn into candidate facts. (The fuzzy extraction lives here, in the session — no new agent.)brain consolidate --propose --candidates <file.json>— validate the candidate file against theCandidateshape (malformed/missing field → non-zero exit, no profile touch — the only enforceable session↔engine contract), runproposeDiffover the current profile + the candidates, and write TWO artifacts tobrain/:pending-diff.md(operator-readable, for theSendUserFilegate) ANDpending-diff.json(the typedProfileDiff, machine-applyable). Print both paths. Does NOT touchprofile.md.brain consolidate --apply --diff brain/pending-diff.json --confirm— apply the confirmed JSON diff toprofile.md(the only path that writes the profile), then update the consolidation sidecar (D3). Operator-gated (requires--confirm); never auto-runs.
-
Last-consolidation state — a brain data-root sidecar
brain/consolidation-state.json({ "last_run": "YYYY-MM-DD" }), written by--applyand read by the session-start nudge. This resolves brief-review B1+B2: both the brain CLI (dataRoot) and the session-start hook (getDataRoot) resolve the data root, so the sidecar is reachable by both — whereas the plugin state file lives in HOME (~/.claude/linkedin-studio.local.md), a SIBLING of the data root the brain CLI cannot reach through its own resolver, andstate-updater.mjshas nolast_import_datewriter to mimic (that scalar is written by the import command via LLM, not a function). Keeping consolidation state in the brain's own data-root territory is also conceptually right (brain state, not plugin state) and zero-dep JSON for the hook to read. -
Session-start wiring (Edit
hooks/scripts/session-start.mjs— existing file, zero-dep, pathguard-safe; all reads via the existinggetDataRoot, the trend-nudge twin idiom):- Scaffold-ensure (zero-dep): mkdir the brain/ + ingest/ dirs if absent (idempotent, no tsx); if
brain/profile.mdis absent, emit a nudge to runbrain init(seeding needs the tsx fold — kept manual, not inlined). - Brain consolidation-due nudge (new-published-count only): read
brain/consolidation-state.jsonlast_run(zero-dep JSON parse) + countingest/published/*.mdrecords (areaddircount — noprofile.mdparse, to respect the 10s budget + avoid a fragile zero-dep fact parser). Trend-nudge discipline:daysSince(last_run)+ threshold + null-safe (a never-consolidated brain with no published records never nags). Emit e.g. "N published posts captured, last consolidation M days ago → runbrain consolidate." - Out of scope (honest): stale-FACT nudging (parsing
profile.mdlast_seen at session-start) is NOT shipped in S2 — it needs a zero-dep fact parser + cost, and bites harder once a reader exists. S2's brain-wide nudge = the new-published/consolidation-due signal only; per-fact decay is computed insideproposeDiff(staleFlags[]), surfaced in the diff, not at session-start.
- Scaffold-ensure (zero-dep): mkdir the brain/ + ingest/ dirs if absent (idempotent, no tsx); if
-
Operator-gated diff presentation — the
--proposeartifact is a readable markdown diff (additions/bumps/promotions/conflicts/stale, sorted, neutral/anti-sycophantic framing); the invoking sessionSendUserFiles it and presents the[OPERATØR]gate before any--apply(the editorial-review idiom). -
Docs —
docs/second-brain/consolidation-loop.md(the loop contract: CLI usage, the engine rules, the operator-gate, the honest limits); this brief + the SB-S2 plan; flip thearchitecture.mdSB-S2 row to landed. -
Tests + gate — engine unit tests (every classification rule + idempotency + provenance-gating + threshold + keep-both + decay +
applyDiffround-trip), CLI tests (gather/propose/apply, no-auto-apply, last-run recording), and a session-start nudge test in the hooks test suite (consolidation-due nudge fires/null-safe). BumpBRAIN_TESTS_FLOOR+ the assertion floor (per the SB-S1-established empirical method) + the hook-test count if applicable.
5.2 What "owns the session-start wiring of the scaffold init" means here
S2 makes the brain present + nudged at session-start without running tsx: it zero-dep-ensures the dirs and nudges brain init (profile seeding stays the manual tsx CLI) + nudges brain consolidate when due. It does NOT run the consolidation pass automatically (that needs AI → operator-invoked).
6. Success criteria (SB-S2 — testable)
Engine rules each get their OWN criterion (fixes brief-review M5; N=3, DECAY_DAYS=90 pinned as the test constants — confirm §9 Q1 at "go" first):
- SC1a (add): a candidate with no matching
observedfact + provenance ∈ {published,human} populatesadditions[]as a dynamic fact,evidence_count:1. - SC1b (reject ai-draft): an
ai-draftcandidate yields an EMPTY diff — no add, no bump, no promote (the model-collapse guard, in code). - SC1c (evidence-bump): a candidate matching an existing fact with the SAME value populates
evidenceBumps[](evidence_count++,last_seen=today), no other field changed. - SC1d (promote): a dynamic fact reaching
evidence_count ≥ 3populatespromotions[](dynamic→static). - SC1e (conflict→keep-both): a candidate matching a key with a DIFFERENT value populates
conflicts[], ADDS the new value as its own fact, and leaves the old fact untouched (no evidence-bump, no supersede) — both retained. - SC1f (decay-flag): a dynamic fact with
last_seenolder than 90 days is listed instaleFlags[]; no auto-demotion. - SC1g (folded immutable): a candidate overlapping a folded
profile-fieldstatic seed creates a separateobservedfact and never mutates the folded one. - SC2 (immutability):
proposeDiffdoes not mutate itscurrent/candidatesinputs (asserted by structural equality of the inputs after the call). - SC3 (apply round-trip):
applyDiff(current, proposeDiff(...))yields aProfileDocthat round-trips exactly throughparseProfile/serializeProfile. - SC4 (idempotency): re-running propose→apply with the same candidates over an already-applied profile yields no spurious additions (evidence-bumps/
last_seenonly; no duplicate facts). - SC5 (CLI gating + validation):
--gatherand--proposenever writeprofile.md(asserted byte-unchanged after each);--proposewritespending-diff.{md,json}tobrain/and REJECTS a malformed candidate file (non-zero exit, no profile touch); only--apply --confirmwritesprofile.mdand updatesbrain/consolidation-state.json. Verified by temp-dir CLI tests. - SC6 (session-start nudge, zero-dep): the consolidation-due nudge fires when published records exist +
last_runis stale/absent, and is silent when the brain is absent / nothing new; the edit adds notsx/heavy dep and parses noprofile.md. Verified by a hook-suite test (HOME +LINKEDIN_STUDIO_DATAset to separate temp dirs — thesession-start-trends-staleness.test.mjsharness pattern — asserting the nudge string appears / is absent). - SC7 (no regression / no new seam / count-neutral):
scripts/test-runner.shgreen; floors hold (trends ≥24, specifics ≥28, contract ≥33, brain ≥ new floor); hook tests pass; no new agent/command/ref/skill (counts unchanged); no new seam function (twin-sync untouched);compile-hooks.py --checkclean ifhooks.jsonis regenerated.
7. Non-goals / scope fence (SB-S2 does NOT)
- No journal-capture (deferred sub-slice) — S2 reads
published+ tributary deltas only; nothing writesbrain/journal/. - No new agent — the fuzzy extraction is done by the invoking session (no
agents/*.md, no reload, no agent-count bump). - No profile.md READER — no content agent/command is wired to consume
brain/profile.md(S3). - No cross-silo id threading into tributaries (S3); no
content-history.mdretirement / triple-post reconciliation (S3). - No AI at session-start — only a deterministic nudge; the consolidation pass is operator-invoked.
- No auto-apply — no diff is ever applied without
--apply --confirm;profile.mdis mutated by exactly one gated path. - No new hook
.mjs— only the existingsession-start.mjsis edited (pathguard: Write of a newhooks/scripts/*.mjsis blocked). - No new seam function, no connector (S4), no GUI.
8. Constraints / risks
- TDD iron law: no production code without a failing test first.
- Code placement / pathguard: engine + CLI are TS under
scripts/brain/(safe). The session-start change is an Edit of the existinghooks/scripts/session-start.mjs(Edit is unaffected by the Category-6 Write guard) — no new.mjsunderhooks/scripts/. - Zero-dep hook discipline: the session-start edit must NOT import tsx/the brain TS, must stay within the 10s timeout, and must use direct raw reads (the trend-nudge twin idiom). The brain consolidation is NOT run there.
- Hook compile: if the SessionStart entry changes shape, regenerate
hooks.jsonviacompile-hooks.py(drift-guarded). Editing only the script body needs no recompile. - Consolidation state is a brain sidecar, NOT the plugin state file (fixes brief-review B1+B2):
brain/consolidation-state.jsonunder the data root, written by--apply(dataRoot) + read by the session-start hook (getDataRoot, zero-dep JSON). Do NOT route it through~/.claude/linkedin-studio.local.md— the brain CLI cannot reach HOME, andstate-updater.mjshas nolast_import_date-style writer to mimic (that scalar is an LLM/command write). Reading it at session-start is aJSON.parse(readFileSync(...)), the trend-trends.json-read twin. - Public repo (
open/): confirm before push; window; STATE.md gitignored. - Count-neutral: no
agents/commands/references/skillsfile added → no count guard fires (absent a version bump). Version bump likely 0.5.1 → 0.5.2 (new capability + session-start behavior) — confirm at "go". - Risk — engine rule ambiguity: the conflict-vs-supersede call is the fuzzy heart. Mitigation: bias to keep-both-timestamped (deterministic + safe), reserve supersede for an explicit confirm signal; pin each rule with a unit test before wiring the CLI.
- Risk — candidate extraction quality: the value of the loop depends on the session extracting good candidates from the gathered deltas; the engine only guarantees the mechanics. Honest limit (the architecture's "voice fidelity is hard"): S2 ships the machinery, not a guarantee of insight.
- Risk — unconsumed output: profile.md has no reader until S3; S2's value is deferred (compounding for later). Accepted by the operator; stated in
consolidation-loop.md. - Risk — session-start cost: the nudge must stay cheap; counting
published/files + one scalar read is O(files) — bound it (e.g. count via readdir, no parse) to respect the 10s budget.
9. Open questions for the operator (settle at "go")
- Promotion threshold N + decay window: default
N = 3independent observations to promote dynamic→static,DECAY_DAYS = 90for stale-flagging. Confirm or adjust. (Recommend N=3 / 90d — pinned as the test constants.) - Version bump: 0.5.1 → 0.5.2 for the new consolidation capability + session-start nudge? (Lean 0.5.2, consistent with SB-S1.)
Resolved by the brief-review (no longer open): conflict = keep-both only, no supersede in S2 (supersede→S3); diff artifacts = brain/pending-diff.{md,json} (git-trackable per "roll-back via git").
10. Definition of done (SB-S2)
SC1a–g + SC2–SC7 pass; deliverables 1–7 exist; non-goals untouched; the engine is provenance-gated + keep-both-only (no supersede) in CODE (not just docs); consolidation state is the brain sidecar (not the plugin state file); the consolidation pass is operator-invoked + the diff operator-gated (no auto-apply); session-start nudge zero-dep (no profile.md parse) + tested; committed (push per window, confirm first); architecture.md SB-S2 row → landed; version/CLAUDE.md/README touched only if a bump is taken.