docs(linkedin-studio): SB-S3c brief + plan — cross-silo id-threading (light-Voyage hardened)
Hub-side design: thread specifics/trends ids onto the brain's published record + a pure analytics resolver (title-prefix + date, confidence tiers). Tributaries untouched; blast radius bounded to scripts/brain/. Answers the north-star query "which raw material performs?" (specific -> post -> analytics). Two hard constraints shaped the slice: profile.md's rigid 6-token grammar keeps the fact->post link OUT (C-1, breaking SCHEMA_VERSION bump); analytics has no body/URN so the analytics<->post join is a resolver, not a stored id (C-2). Light-Voyage: brief-review APPROVE-WITH-FIXES (5 FIX), plan-critic REVISE -> 1 BLOCK + 4 MAJOR + 4 MINOR folded, scope-guardian ALIGNED. Awaiting operator go-before-code gate before any TDD. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RigJBiRFNtFZKCz21qNbQ4
This commit is contained in:
parent
585f972a05
commit
016d823f3b
2 changed files with 229 additions and 0 deletions
115
docs/second-brain/brief-sb-s3c.md
Normal file
115
docs/second-brain/brief-sb-s3c.md
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
# Brief — SB-S3c: cross-silo id-threading (the "which raw material performs?" graph)
|
||||
|
||||
> **Slice:** SB-S3c (third sub-slice of SB-S3). **Status:** DRAFT — awaiting operator "go" before any code. Light-Voyage **complete**: brief-review **APPROVE-WITH-FIXES** (5 FIX folded §9), plan-critic **REVISE → 1 BLOCK + 4 MAJOR + 4 MINOR folded** (§10), scope-guardian **ALIGNED** (0 creep/0 gap).
|
||||
> **Predecessors:** SB-S0 (id/provenance substrate — `mintEntityId`/`mintContentId`, `id.ts:6-7` reserved "SB-S3 will thread this id through the tributaries") · SB-S1 (published-gold ingest — the post anchor `ingest/published/<contentId>.md`) · SB-S2 (consolidation motor) · SB-S3a (first reader `strategy-advisor` — LANDED `4fa411f`) · **SB-S3b (supersede arm — LANDED `585f972`, gate 95/0/0, BRAIN floor 94, ASSERT floor 80).**
|
||||
> **Grounded in:** a read-only code map of all four tributaries + `scripts/brain/` (file:line cited throughout) — Explore sweep + firsthand reads of `id.ts`, `consolidate.ts`, `types.ts`, `profile.ts`, `ingest.ts`.
|
||||
|
||||
## 1. Operator decision (2026-06-23)
|
||||
|
||||
SB-S3 was decomposed into four sub-slices; standing operator direction is **S3a (reader) → S3b (supersede) → S3c (id-threading) → S3d (hygiene+ops, last)**. S3a + S3b have landed. **S3c is next** — it is the slice the whole arc was built toward: the architecture's single hardest problem (`architecture.md:17`) — *"A published post lands in three non-referencing places with no shared id. The question 'which raw material actually performs?' (specific → post → measured analytics) is **unanswerable today**"* — and the explicit SB-S3 payoff (`architecture.md:54`, `:80`: *"thread the id through tributaries → post ↔ specific ↔ trend ↔ analytics assemblable"*).
|
||||
|
||||
The go-before-code gate still applies per slice: this brief + the plan are presented for operator "go" (with the genuine forks in §8) before any code.
|
||||
|
||||
## 2. The north-star query, and the central design decision
|
||||
|
||||
**What S3c must make answerable:** given a published post, **what raw material fed it** (the specific(s) that grounded it, the trend(s) that inspired it) and **how did it perform** (its measured analytics). The killer second-brain query is `specific → post → measured analytics`: *which raw material actually performs?*
|
||||
|
||||
**The grounding surfaced two hard constraints that reshape the slice** (this is why the design below is leaner — and more arc-aligned — than the STATE's anticipated "mutate every tributary schema"):
|
||||
|
||||
- **C-1 — the profile grammar is rigid.** `brain/profile.md` is a fixed **6-token line-grammar** (`profile.ts:27-37,57-58`; `FACT_RE`; `parse∘serialize===doc`, SC2). Persisting a fact→post provenance link **onto the fact** would add a 7th token = a **breaking `SCHEMA_VERSION` bump** (`types.ts:26`, "bumped only on a breaking grammar change") + a migration of every existing `profile.md`. That is its own slice, not S3c. **OUT (§4).**
|
||||
- **C-2 — analytics has no body and no URN.** The LinkedIn CSV export carries only `title` (the post-content column, taken **verbatim** by the parser — `csv-parser.ts:181`; the "first ~100 chars" in `models/types.ts:3` is a property of *LinkedIn's export*, not a code truncation) + `publishedDate` + metrics — **no full body, no permalink/URN anywhere in the codebase**. So analytics **physically cannot compute** the brain's `mintContentId(body)` (`id.ts:51`, body-byte-exact). Nor does its own `id` help: `PostAnalytics.id = hash(title + date)` (`csv-parser.ts:138`) is derived from the **very fields the resolver already joins on**, so it carries **zero independent linking signal**. The analytics↔post link therefore **cannot be a stored shared id** — it must be a **resolver** (join by title-prefix + date). This is an honest heuristic, not a guarantee, and the design names it as such.
|
||||
|
||||
**Central design decision — thread the cross-ids onto the HUB-side post record, not into each tributary; assemble analytics by resolver.** The architecture is explicit that **tributaries stay separate and the hub holds the distilled summary + pointers** (`architecture.md:43,49`: *"Keep tributaries; add a thin hub + a cross-silo id"*; *"Forcing one schema would destroy [each silo's] distinctions"*). The temporally-correct anchor is the **post**: trends + specifics exist *before* a post and feed it; analytics measures it *after*. The post's canonical object already exists — `ingest/published/<contentId>.md` — and it already carries the content-id (filename stem + `id:` header). Its **parse** side is tolerant of added header lines (`headerScalar` reads each key by regex, `ingest.ts:61-65`), so threading new fields is round-trip-safe; the **serializer** is a fixed 5-line array (`ingest.ts:51-57`) that S3c **edits** to conditionally append the two new lines (this is a deliberate edit, not an auto-handled field — corrected per brief-review FIX 5). Unlike the rigid profile grammar, this header grammar takes additive optional lines cleanly. So:
|
||||
|
||||
1. the **published record** carries the post's raw-material provenance as **additive, optional header lines** (`specifics:` + `trends:`, lists of tributary ids); and
|
||||
2. a **pure assembler** joins published-record ↔ analytics-row by title-prefix + date (with a confidence tier) to attach measured performance.
|
||||
|
||||
This keeps **all four tributary schemas (trends, specifics, analytics, post-tracking) untouched**, needs **no analytics base-36-id reconciliation**, and is strictly additive + backward-compatible — while delivering the exact graph the north-star query needs. *(That S3c's blast radius is bounded to the `scripts/brain/` package, not the tributaries, is a finding that corrects the STATE's "rører tributær-skjemaer" expectation — surfaced as the headline fork, §8 Fork-1.)*
|
||||
|
||||
## 3. Scope — what is IN (S3c)
|
||||
|
||||
1. **Published record carries raw-material provenance (additive, optional).** Extend `PublishedRecord` (`ingest.ts:27-40`) with `specifics: string[]` + `trends: string[]` (tributary ids the post was built from; default empty). `serializePublishedRecord` (`ingest.ts:50-58`) emits them as header lines **only when non-empty** (omit-empty → old records serialize byte-identically); `parsePublishedRecord` (`ingest.ts:75-99`) reads them (absent → `[]`). Ids are validated to the 12-hex shape (the existing `id` guard idiom, `ingest.ts:82`); a malformed id throws (never silently dropped). The `\n---\n` sentinel split is unaffected (header lines never contain it).
|
||||
|
||||
2. **Producer surface — tag raw material at ingest.** `ingestText` (`ingest.ts:165-180`) accepts optional `specifics?: string[]` / `trends?: string[]`, threaded onto the record. The `brain ingest` CLI gains **repeatable** `--specific <id>` / `--trend <id>` flags so the operator can tag a post's raw material at capture time. **This requires a parser change (FIX 1):** the shared `parseFlags` (`cli.ts:44-60`) returns `Record<string,string>` and **overwrites** on a repeated key (`out[key] = next`), so it cannot collect `[a,c]`. S3c extends flag-collection to gather repeated `--specific`/`--trend` into arrays — **without changing single-value flag behaviour** (`--file`/`--source`/`--date`/`--scan-inbox` parse exactly as today; regression-pinned, SC12). Minimal-disruption shape (a dedicated repeatable-flag pass for the two keys, or a `string|string[]` collect) is pinned in the plan. This makes the substrate **immediately usable end-to-end** (not fixture-only): ingest a post tagged `--specific X` → assemble → see the graph. Backward-compatible (flags absent → empty arrays = today's behaviour).
|
||||
|
||||
3. **The assembler (the payoff) — a PURE function.** `assemblePostGraph({ records, analytics })` over already-loaded inputs (no I/O in the core) returns, per published post: its `contentId`, its `specifics[]`/`trends[]` ids, and its **matched analytics** — `{ row, confidence }` carrying the **whole `PostAnalytics` row reference** (FIX 4: the assembler attaches the full row; which metric the *CLI renders* is Fork-4, decoupling the data shape from presentation), or `none`.
|
||||
**The join rule (named here, not deferred — FIX 3), absorbing the title↔body asymmetry (verbatim CSV title vs byte-exact body, C-2):** normalize **both** sides with the brain's content normalization (`normalizeContent` idiom: lowercase + collapse-whitespace + trim — the plan pins the exact fn / shared helper) and require **`norm(title)` is a prefix of `norm(body)`** with a **minimum prefix-length floor** (a too-short title must not false-match any body — floor value pinned in the plan, e.g. ≥ N chars). **Confidence tiers:** `high` = prefix-match (≥ floor) + same `published_date`; `low` = prefix-match (≥ floor) but date off by ≤ a small tolerance, OR a near-miss where LinkedIn truncated the title mid-word (the body's normalized text starts with `norm(title)` minus a trailing partial token); `none` = no qualifying prefix match. Deterministic, fully unit-testable (incl. a truncation near-miss case, SC7). The assembler reads tributary ids **as opaque 12-hex strings** and takes a **minimal analytics input shape** (`{ title, publishedDate, metrics, … }` — the raw `PostAnalytics` JSON shape, `models/types.ts:1-8`), so it does **not** import the trends/specifics/analytics packages (decoupled, §5).
|
||||
|
||||
4. **A thin read-only IO + CLI surface.** A `brain assemble` (or `brain graph`) subcommand: loads published records (`dataRoot('ingest/published')`) + the analytics batches by **inlining a raw-JSON read** of `dataRoot('analytics/posts')/*.json` (each file is an `AnalyticsBatch` with `.posts[]`; storage path `<root>/posts/<date>-<shortId>.json`, `storage.ts:146-152`) — **NOT** by importing the analytics package's `loadAllPosts` (`storage.ts:197`), per the §5 decoupling. It extracts the minimal shape from each `posts[]` entry (note the field is **`publishedDate`**, not `date` — RISK B), runs `assemblePostGraph`, and prints the post → raw-material → performance join (newest first). **Read-only** — it never writes the brain. A missing `analytics/posts/` (gitignored — absent on a fresh clone, RISK C) or missing `ingest/published/` degrades to an empty/partial graph (every post `match: none`), never a throw. *(Fork-3, §8: pure-function-only with CLI deferred is the lighter alternative.)*
|
||||
|
||||
5. **Gate + doc reconciliation.** Bump `BRAIN_TESTS_FLOOR` (currently **94**, `test-runner.sh:716`) by the new brain-test count; **`ASSERT_BASELINE_FLOOR` stays 80** — no new *unconditional structure-lint* check (the new tests are brain-suite tests, counted by `BRAIN_TESTS_FLOOR`, same idiom as S3b §3.7). Reconcile the now-true status lines (`consolidation-loop.md`, `architecture.md:80` SB-S3 row, CLAUDE.md/STATE counts, the `id.ts:6-7` "SB-S3 will thread" comment).
|
||||
|
||||
## 4. Non-goals — what is OUT (deferred)
|
||||
|
||||
- **Persisting the fact→post link on `brain/profile.md` (the profile-grammar bump, C-1).** The post→specifics/trends graph + the analytics resolver answer the north-star query **without** touching the rigid 6-token grammar. A 7th `source`/provenance-id token is a breaking `SCHEMA_VERSION` change with a migration — its own slice. The fact already carries `provenance`; that is enough for S3c.
|
||||
- **Mutating the tributary schemas (trends/specifics/analytics/post-tracking).** Per `architecture.md:43,49` tributaries stay separate; the cross-ids live hub-side on the post record (§2). No `TrendRecord`/`Specific`/`PostAnalytics` field is added; no analytics base-36-id → sha256 reconciliation. *(Fork-1 is exactly this choice — operator may redirect to the broader mutate-tributaries shape.)*
|
||||
- **post-tracking (`state-updater.mjs`) content-id.** The loosest silo (regex-mutated markdown, prune-regex risk, `state-updater.mjs:116,145`) and **not on the `specific→post→analytics` critical path**. The post-tracking ↔ published ↔ analytics "triple-post reconciliation" is the named **S3d** concern (`architecture.md:80`). OUT here.
|
||||
- **A producer that auto-records which specifics/trends a draft used.** S3c gives the substrate + the **manual** `--specific/--trend` tag at ingest (§3.2). Auto-capture from a drafting command (e.g. `/linkedin:newsletter` recording its `livedSpecifics`) is follow-on wiring, like S3a wired one reader.
|
||||
- **A fuzzy/semantic analytics match beyond title-prefix + date.** No embedding/edit-distance match. If title-prefix+date doesn't resolve it, it's `none` (honest), not a guess. Tightening the matcher is later, additive.
|
||||
- **Backfilling existing untagged published records.** Old records parse with empty `specifics/trends` and round-trip byte-identically; no migration pass.
|
||||
|
||||
## 5. Boundaries / invariants (must hold)
|
||||
|
||||
- **Tributaries untouched.** Zero schema/field change to trends, specifics, analytics, post-tracking. The assembler **reads** their ids/rows; it never writes them.
|
||||
- **Additive + backward-compatible.** A published record with no specifics/trends serializes **byte-identically** to today (omit-empty); `parse(serialize(rec))===rec` holds with the new fields; `serialize(parse(oldText))===oldText` for pre-S3c records. `mintContentId` is **body-only**, so adding headers never changes a record's id/filename (no dedupe disturbance).
|
||||
- **Pure core, single read-only IO seam.** `assemblePostGraph` is pure (inputs in, graph out — no clock/FS/network). The only IO is the read-only `brain assemble` loader; **no new disk writer** (the `--apply --confirm` consolidation path stays the sole `profile.md` writer; ingest stays the sole `published/` writer).
|
||||
- **Decoupled packages.** The brain assembler does not import the analytics/trends/specifics TS packages; it takes a minimal analytics input shape and treats tributary ids as opaque 12-hex strings.
|
||||
- **Honest matching.** The analytics↔post join is a **named heuristic** with explicit confidence tiers and a `none` outcome — never presented as a guaranteed key. The CLI shows the confidence so the operator can eyeball low-confidence matches.
|
||||
- **Anti-sycophancy / provenance spine intact** — S3c adds a read path; it does not weaken the published-only learning guard or the profile.
|
||||
- **Fresh-clone safe.** Missing `ingest/published/` or missing analytics → the assembler returns an empty/partial graph and the CLI degrades cleanly (no crash, no nag).
|
||||
- **TDD iron law** — each rule (round-trip with the new fields, the producer threading, the match-tier logic, the graceful-absence path) is pinned by a failing brain-suite test before the code.
|
||||
|
||||
## 6. Success criteria (testable — deterministic, unit-level)
|
||||
|
||||
- **SC1 — record round-trip with raw-material ids:** a `PublishedRecord` with non-empty `specifics`/`trends` satisfies `parsePublishedRecord(serializePublishedRecord(rec)) === rec` (deep-equal); ids preserved in order. *(unit)*
|
||||
- **SC2 — byte-identical backward-compat (byte contract pinned, FIX 2):** the new header lines, when present, are appended **after `source:`** and before the `\n---\n` sentinel; a record with **empty** `specifics`/`trends` emits the **unchanged 5-line header** (omit-empty), so against a fixture pre-S3c record string `oldText`, `serializePublishedRecord(parsePublishedRecord(oldText)) === oldText` (byte-equal). *(unit)*
|
||||
- **SC3 — id validation (parse + producer, FIX-add):** a `specifics`/`trends` entry that is not 12-hex throws on `parsePublishedRecord` (never silently dropped), mirroring the `id` guard (`ingest.ts:82`); and the symmetric producer-boundary case — `ingestText({ specifics:["XYZ"] })` with a non-12-hex id — is rejected (validated at the producer, or guaranteed to throw on the immediate round-trip; the plan picks the seam). *(unit)*
|
||||
- **SC4 — producer threading:** `ingestText({ ..., specifics:[a], trends:[b] })` writes a record whose round-tripped header carries exactly `[a]`/`[b]`; absent → `[]` (today's behaviour, regression pin). *(unit)*
|
||||
- **SC5 — CLI repeatable flags:** `brain ingest --specific a --specific c --trend b` produces a published record tagged `specifics:[a,c] trends:[b]`. *(cli — brain suite)*
|
||||
- **SC6 — assemble, high confidence (full row, FIX 4):** for a record whose normalized body begins with an analytics row's normalized title (≥ prefix-floor) and shares its `publishedDate`, `assemblePostGraph` returns that post with `match.confidence === "high"`, the **whole `PostAnalytics` row reference** attached, and its `specifics`/`trends` ids surfaced. *(unit)*
|
||||
- **SC7 — assemble, low / none / truncation near-miss (FIX 3):** (a) no qualifying prefix match → `match: none` (never a false `high`); (b) a too-short title (< prefix-floor) that is a literal prefix → **not** `high` (floor guards false positives); (c) a LinkedIn-truncated-mid-word title (body starts with `norm(title)` minus a trailing partial token) and/or a date off by ≤ tolerance → `"low"`, per the §3.3 rule. *(unit)*
|
||||
- **SC8 — assemble is pure + total:** empty `records` → empty graph; empty `analytics` → every post present with `match: none`; no throw on either. *(unit)*
|
||||
- **SC9 — read-only CLI:** `brain assemble` prints the join (post → specifics/trends → matched analytics + confidence), newest-first, and **writes nothing** (no `profile.md`/`published/` mutation — asserted). Missing analytics/published (RISK C, fresh-clone) → clean partial/empty output, no crash. *(cli — brain suite)*
|
||||
- **SC10 — gate green:** brain suite ≥ bumped `BRAIN_TESTS_FLOOR`; `scripts/test-runner.sh` green; `ASSERT_BASELINE_FLOOR` unchanged at 80. *(gate)*
|
||||
- **SC11 — no regression:** ingest dedup/collision-safety, the published-only invariant, consolidation, supersede — all unchanged (the new fields are additive). *(unit/regression pins)*
|
||||
- **SC12 — single-value flags unregressed (FIX 1):** after the repeatable-flag change, the existing single-value flags (`--file`/`--source`/`--date`) and boolean flags (`--scan-inbox`/`--confirm`) parse exactly as today across all subcommands. *(unit/cli)*
|
||||
|
||||
## 7. Verification
|
||||
|
||||
- **Deterministic (the whole slice):** SC1–SC11 are unit/CLI tests in `scripts/brain/tests/` + the structure-lint gate. **Like S3b (and unlike S3a), S3c has no behavioural-only SC** — the substrate, the producer, and the assembler are pure/CLI mechanics, fully exercisable in the harness with fixtures.
|
||||
- **Run:** `(cd scripts/brain && npm test)` then `bash scripts/test-runner.sh`.
|
||||
- **End-to-end demo (documented, optional):** with a real `LINKEDIN_STUDIO_DATA`: import an analytics CSV (existing path) → `brain ingest --specific <id>` a matching published post → `brain assemble` → confirm the post shows its specific + the matched analytics row. Records the north-star query working once, manually.
|
||||
|
||||
## 8. Open questions / genuine forks for brief-review + the operator (the go-gate)
|
||||
|
||||
1. **Fork-1 (headline — where the cross-ids live).** **RECOMMENDED: hub-side** — thread `specifics`/`trends` ids onto the brain's published record + resolve analytics (§2). Tributaries stay untouched; blast radius is bounded to `scripts/brain/`; most arc-aligned (`architecture.md:43`). **Alternative:** the STATE-anticipated *mutate-every-tributary* shape (add a cross-id field to `TrendRecord`/`Specific`/`PostAnalytics` + reconcile analytics' base-36 id) — higher blast, crosses 3 package boundaries + 3 gates, and (per C-2) still can't give analytics a real shared id without a resolver. The recommendation is to take the leaner hub-side cut; **operator confirms or redirects.**
|
||||
2. **Fork-2 (analytics↔post join).** **RECOMMENDED: pure resolver by title-prefix + date with confidence tiers** (§3.3) — the only mechanism available (C-2: no body/URN). Accept the heuristic + `none` outcome as honest. **Alternative:** require a manual `analyticsId` tag on the published record too (operator types it at ingest) — more precise but more friction and still operator-supplied. Recommend the resolver; a manual tag can be added later, additively.
|
||||
3. **Fork-3 (assembler surface).** **RECOMMENDED: pure `assemblePostGraph` + a thin read-only `brain assemble` CLI** (demonstrable end-to-end). **Alternative:** pure function + tests only, CLI deferred to when a reader/command consumes the graph (smaller, but no operator-visible payoff this slice). Recommend including the thin CLI.
|
||||
4. **Fork-4 (metric *rendered* — presentation only, FIX 4).** The assembler always attaches the **whole `PostAnalytics` row reference** (data shape fixed; SC6 depends on it). Fork-4 governs only what the **CLI renders** as the headline "performance" figure — `engagementRate` (compact) vs a small bundle (impressions + engagementRate + saves). RECOMMEND `engagementRate` as the headline with the row available for a `--verbose` expansion. Plan pins the rendered field; the data shape is not in question.
|
||||
|
||||
## 9. Brief-review (light-Voyage) — folded
|
||||
|
||||
`voyage:brief-reviewer` verdict: **APPROVE-WITH-FIXES**. The central design (hub-side id-threading + analytics resolver) was confirmed sound, honestly bounded against both C-1 and C-2, with a clean IN/OUT boundary (no broken half-state) and verified gate mechanics (`BRAIN_TESTS_FLOOR=94` `test-runner.sh:716`, `ASSERT_BASELINE_FLOOR=80` `:913`). Fork-1 was judged framed honestly (not a rigged fork — the recommendation concedes the alternative's only real advantage and notes C-2 defeats even that). All 5 [FIX] folded above:
|
||||
|
||||
- **[FIX 1]** repeatable-flag blocker — `parseFlags` (`cli.ts:44-60`) returns `Record<string,string>` and overwrites on repeat → §3.2 now scopes the collect-into-arrays change + SC12 regression pin (the most material fix; SC5 was unsatisfiable as written). ✅
|
||||
- **[FIX 2]** SC2 byte contract pinned — new lines appended **after `source:`**, omit-empty, fixture `oldText` byte-equality (`ingest.ts:51-57`). ✅
|
||||
- **[FIX 3]** §3.3 join rule named in-brief (not deferred) — both-side `normalizeContent` + minimum prefix-length floor + truncation near-miss tier; SC7 expanded to floor + near-miss cases. ✅
|
||||
- **[FIX 4]** assembler attaches the **full `PostAnalytics` row reference**; Fork-4 governs only CLI *rendering* — decouples SC6 (data) from the open fork. ✅
|
||||
- **[FIX 5]** §2 citation corrected — *parse* is forgiving (`headerScalar`, `ingest.ts:61-65`); *serializer* is a fixed 5-line array (`:51-57`) S3c edits; + the C-2 note that `PostAnalytics.id = hash(title+date)` carries no independent linking signal. ✅
|
||||
|
||||
The 3 [RISK] are carried as explicit plan obligations (see §3.3/§3.4/§5; the plan's risk ledger pins them):
|
||||
- **[RISK A]** all SCs use synthetic fixtures; the §7 end-to-end demo is the only real-data exercise of the resolver. A green gate does not prove a real LinkedIn CSV title matches a real captured body — the plan keeps the demo and treats a demo `none` as a *normalization-tightening signal* (honest-limit line, mirroring `consolidation-loop.md`/S3b §3.7), not a slice failure.
|
||||
- **[RISK B]** `brain assemble` reads `analytics/posts/*.json` by inlining a raw-JSON read (`dataRoot('analytics/posts')`), **not** `loadAllPosts` — and extracts from `publishedDate` (not `date`). Pinned in §3.4.
|
||||
- **[RISK C]** fresh-clone: `analytics/posts/` is gitignored/absent → the loader must degrade to every-post-`none` (SC8/SC9), never throw. Pinned in §3.4/§5.
|
||||
|
||||
## 10. Plan-critic + scope-guardian (light-Voyage) — folded
|
||||
|
||||
`voyage:plan-critic`: **REVISE → 1 BLOCK + 4 MAJOR + 4 MINOR**, all folded into `plan-sb-s3c.md` (the brief's design held; these were plan-precision defects):
|
||||
- **[BLOCK]** the plan misstated `runIngest`/`main` wiring — `runIngest(flags)` is dispatched without `rest` (`cli.ts:92,238`); Step 4.2 now explicitly changes the signature AND the call site so `collectRepeated(rest,…)` has its input.
|
||||
- **[MAJOR]** parse must NOT reuse `headerScalar` (`ingest.ts:61-65`) — it throws on absent keys, breaking every old record (SC2/SC11); Step 2.2 pins a non-throwing optional reader.
|
||||
- **[MAJOR]** analytics-root skew — `ANALYTICS_ROOT` override (`storage.ts:67-72`) honoured by the analytics package but not by brain `dataRoot`; Step 3.5 + R-B name it as a doc-comment caveat (degrades to `none`, not silent).
|
||||
- **[MAJOR]** `runAssemble` loader shape — needs full `PublishedRecord[]` (for `.body`), not `listPublished`'s summary; Step 4.3 pins full-record load, locally-derived `firstLine`, explicit desc sort.
|
||||
- **[MAJOR]** producer-boundary throw (SC3) under-specified — Step 2.3 pins the guard (regex/message/placement) + empty-array skip so `scanInbox`/SC11 stay green.
|
||||
- **[MINOR ×4]** tie-break determinism (Step 3.4 sorts rows), `PREFIX_FLOOR=24` rationale (Step 3.3), floor sub-count reconciliation (R-G), Step 1 compile-fixup of the two existing record literals.
|
||||
|
||||
`voyage:scope-guardian`: **ALIGNED** — 0 creep / 0 gap. All SC1–SC12 + all 5 FIXes + all 3 RISKs map to concrete plan steps; every brief §4 OUT item is genuinely excluded; the "Not touched" scope fence is accurate (four tributaries untouched, analytics READ-only via inlined JSON, hooks/agents/profile-grammar/`parseFlags` untouched).
|
||||
114
docs/second-brain/plan-sb-s3c.md
Normal file
114
docs/second-brain/plan-sb-s3c.md
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
# Plan — SB-S3c: cross-silo id-threading
|
||||
|
||||
> Brief: `docs/second-brain/brief-sb-s3c.md`. Slice: SB-S3c.
|
||||
> **Design (recommended, pending operator go at the gate):** hub-side threading — `specifics`/`trends` ids onto the brain's published record + a pure analytics resolver. Tributaries untouched. (Fork-1; operator may redirect to mutate-tributaries.)
|
||||
> **Light-Voyage:** brief-review APPROVE-WITH-FIXES (5 FIX folded into the brief; 3 RISK carried here). plan-critic **REVISE → 1 BLOCK + 4 MAJOR + 4 MINOR folded** (Step 1 compile-fixup · Step 2.2 non-throwing parse reader · Step 2.3 producer guard · Step 3.3 floor rationale · Step 3.4 deterministic row-sort · Step 3.5 analytics-root caveat · Step 4.2 `runIngest` signature+call-site · Step 4.3 full-record load + desc sort · R-G sub-count). scope-guardian **ALIGNED** (0 creep / 0 gap).
|
||||
> Order is TDD: failing brain-suite tests land BEFORE the code (iron law); each rule pinned before the CLI is wired.
|
||||
|
||||
## Goal
|
||||
|
||||
A published post records the **raw material it was built from** (`specifics`/`trends` ids, additive + backward-compatible on the post record), and a **pure assembler** joins published-record ↔ analytics-row (by normalized title-prefix + date, with honest confidence tiers) so the north-star query — *which raw material actually performs?* (`specific → post → measured analytics`, `architecture.md:17`) — is **assemblable**. All four tributary schemas stay untouched; the only new disk surface is a **read-only** `brain assemble`. Fully unit/CLI-testable with fixtures.
|
||||
|
||||
## Files touched (exhaustive — for scope-guardian)
|
||||
|
||||
| File | Change | SC |
|
||||
|------|--------|-----|
|
||||
| `scripts/brain/src/ingest.ts` | `PublishedRecord` (+`specifics: string[]` +`trends: string[]`, `:27-40`) · `serializePublishedRecord` append the two lines **after `source:`**, **omit-empty** (`:50-58`) · `parsePublishedRecord` read them via a new list-scalar reader, **12-hex-validate each** (reuse the `:82` guard idiom), absent→`[]` (`:75-99`) · `ingestText` (+optional `specifics?`/`trends?`, `:165-180`) | SC1,SC2,SC3,SC4,SC11 |
|
||||
| `scripts/brain/src/assemble.ts` | **NEW.** Pure `assemblePostGraph({records, analytics})` + local `normalize()` + `matchRow()` (tiers) + minimal input type `AnalyticsRowInput` + output types · thin read-only `loadAnalyticsRows()` IO (inline raw-JSON read of `dataRoot('analytics/posts')/*.json` → `AnalyticsBatch.posts[]`; **never** imports the analytics package) | SC6,SC7,SC8 |
|
||||
| `scripts/brain/src/cli.ts` | `collectRepeated(args,key): string[]` helper (leaves `parseFlags` **untouched** — SC12 by construction) · **change `runIngest` signature `(flags)`→`(rest, flags)` AND the `main` dispatch `runIngest(flags)`→`runIngest(rest, flags)`** (`:92`, `:238` — BLOCK 1), thread `collectRepeated(rest,"specific")`/`"trend"` · `runAssemble(flags)` (read-only print, full-record load) · dispatch `+ if (command==="assemble")` (`:240`) · `usage` text (+`assemble`, +ingest flags) | SC5,SC9,SC12 |
|
||||
| `scripts/brain/tests/ingest.test.ts` | SC1 (round-trip w/ ids) · SC2 (byte-identical old-record fixture) · SC3 (parse + producer malformed-id throw) · SC4 (producer threading) · SC11 (dedup/collision/published-only regress) | unit |
|
||||
| `scripts/brain/tests/assemble.test.ts` | **NEW.** SC6 (high) · SC7 (none / below-floor / ellipsis-near-miss / different-date→low) · SC8 (pure+total) | unit |
|
||||
| `scripts/brain/tests/cli.test.ts` | SC5 (repeatable flags) · SC9 (read-only assemble print + missing-dir degrade) · SC12 (single-value flags unregressed) | cli |
|
||||
| `scripts/test-runner.sh` | bump `BRAIN_TESTS_FLOOR` 94→(94+N) + breakdown comment (`:716`); **`ASSERT_BASELINE_FLOOR` UNCHANGED at 80** | SC10 |
|
||||
| `docs/second-brain/consolidation-loop.md` · `architecture.md` · `scripts/brain/src/id.ts` | reconcile status to true state: S3c threads the cross-silo graph; `id.ts:6-7` "SB-S3 will thread" → "S3c threads (post→specifics/trends + analytics resolver)"; `architecture.md:80` SB-S3 row note | doc |
|
||||
| `CLAUDE.md` · STATE | telling/counts: brain test count, S3c landed line; reference-doc count unchanged (briefs aren't counted reference docs) | doc |
|
||||
|
||||
**Not touched (scope fence):** `scripts/trends/**` · `scripts/specifics-bank/**` · `scripts/analytics/**` (READ-only, via inlined JSON — no import, no schema/field change, no base-36-id reconciliation) · `hooks/scripts/**` (no `.mjs`; pathguard untouched) · `agents/**` (no reader wired this slice) · `scripts/brain/src/{consolidate,types,profile,scaffold,dataRoot}.ts` (no consolidation/grammar/scaffold change) · `brain/profile.md` grammar (C-1 — the 7th-token bump stays OUT) · `parseFlags` (untouched — `collectRepeated` is additive) · the plugin command surface (29 unchanged — `brain assemble` is a TS-CLI subcommand, not a `/linkedin:*` command).
|
||||
|
||||
## Step 0 — pre-flight (verify baseline, no edits)
|
||||
|
||||
`(cd scripts/brain && npm install)` if `node_modules` absent. Confirm `(cd scripts/brain && npm test)` = **94/94** and `bash scripts/test-runner.sh` green (95/0/0, `BRAIN_TESTS_FLOOR=94`, `ASSERT_BASELINE_FLOOR=80`). Re-read `ingest.ts:50-99` (serialize/parse round-trip), `cli.ts:44-60,92-116,233-245` (parseFlags/runIngest/main). Confirm **no structure-lint** in `test-runner.sh` asserts the brain-CLI subcommand set (so `assemble` adds no lint debt) and that `specifics-bank/src/bank.ts:52` `normalizeContent` is the idiom to **copy locally** (not import).
|
||||
|
||||
## Step 1 — (RED) types + failing tests
|
||||
|
||||
1. **Type scaffolding + compile-fixups** (suite still compiles, 94 green):
|
||||
- `PublishedRecord` (`ingest.ts:27-40`): `+ specifics: string[]; + trends: string[];` (required).
|
||||
- **Compile-fixup (plan-critic MINOR 4 — REQUIRED fields, so existing full-`PublishedRecord` literals must gain them or the suite won't compile):** add `specifics: [], trends: []` to the literals at `tests/ingest.test.ts:11` (`baseRec`) and `tests/publish.test.ts:78` (`squatter`). Tests that build records via `ingestText`/`parsePublishedRecord` need no change.
|
||||
- `ingestText` opts (`:165-180`): `+ specifics?: string[]; + trends?: string[];`; record literal sets `specifics: opts.specifics ?? []`, `trends: opts.trends ?? []`.
|
||||
- **`parsePublishedRecord` defaults the new fields to `[]` from the start** (absent → `[]`) — so existing round-trips (records carrying `[]`) stay **green** at Step 1 (serialize omits empty, parse sets `[]`, deep-equal holds). The *new behaviour* (emit non-empty, read present values, validate, producer-throw) is Step 2 RED→GREEN.
|
||||
- `assemble.ts`: stub `assemblePostGraph` returning `[]` + the types; `normalize`/`matchRow` declared.
|
||||
2. **Add failing tests** → expected FAIL: SC1 (parse must read **non-empty** lines), SC2 (byte-identical fixture — fails until omit-empty serialize), SC3 (validate-throw), SC4 (producer threading with non-empty ids), SC6/SC7/SC8 (assembler logic), SC5/SC9/SC12 (CLI). Existing 94 (carrying `[]`) PASS.
|
||||
|
||||
**RED gate (single, unambiguous):** run **`(cd scripts/brain && npm test)` directly** → new tests fail, existing 94 **compile** (with the two added literal fields) and **pass** (parse defaults `[]`). **Do NOT run `bash scripts/test-runner.sh` at RED** (Section 16b fails the whole gate on a non-zero brain exit — design-noise, per S3b plan `:38`). The direct brain-suite RED is the failing-test proof; the gate runs GREEN-only after Step 4.
|
||||
|
||||
## Step 2 — (GREEN) the post record carries raw material
|
||||
|
||||
1. **`serializePublishedRecord`** (`ingest.ts:50-58`): after the fixed 5-line header array, conditionally append — **only when non-empty** — `specifics: <id,id>` then `trends: <id,id>` (comma-joined, no spaces), **before** the `SENTINEL`. Empty array → line omitted → **byte-identical 5-line header** (SC2). Order fixed: `…source:` → `specifics:`(if any) → `trends:`(if any) → `---`.
|
||||
2. **`parsePublishedRecord`** (`ingest.ts:75-99`): a **new non-throwing optional-list reader** — **NOT `headerScalar`** (plan-critic MAJOR 1: `headerScalar` `:61-65` THROWS `missing "<key>:" header` on an absent key, which would break **every** pre-S3c record / SC2 / SC11). Instead: `const m = header.match(new RegExp(\`^${key}:\\s*(.*?)\\s*$\`, "m")); if (!m) return []` (absent → `[]`); else split the captured value on `,`, trim, drop empties; **validate each id `/^[0-9a-f]{12}$/`** (the `:82` hex guard) → a non-12-hex entry **throws** `malformed published record: bad <specifics|trends> id <JSON.stringify(entry)>` (never silently dropped). Read from the **header slice only** (pre-sentinel), so a body line that looks like `specifics:` cannot leak.
|
||||
3. **Producer-side validation (SC3 symmetric, plan-critic MAJOR 4 — specify the guard):** in `ingestText` (`:165-180`), BEFORE `mintContentId`, validate each id of `opts.specifics ?? []` and `opts.trends ?? []` against `/^[0-9a-f]{12}$/`; a non-match throws `ingest: bad <specifics|trends> id <JSON.stringify(entry)>`. **Empty/absent arrays skip validation** (no throw) — so `scanInbox` (`:204`, calls `ingestText` with no specifics/trends) and every existing caller stay green (SC11). This adds a throw path to `ingestText`'s previously throw-free contract — that is intended and bounded to malformed-id input.
|
||||
|
||||
**GREEN gate (record):** `(cd scripts/brain && npm test)` → SC1–SC4 + SC11 pass; existing 94 pass.
|
||||
|
||||
## Step 3 — (GREEN) the assembler (the payoff)
|
||||
|
||||
In `assemble.ts`:
|
||||
|
||||
1. **`normalize(s)`** (local copy of the `normalizeContent` idiom — NOT imported): `s.normalize?` → `s.trim().toLowerCase().replace(/\s+/g, " ")`.
|
||||
2. **Title prep:** `nt = stripTrailingEllipsis(normalize(title))` where `stripTrailingEllipsis` removes a trailing `…` or `...` (+ surrounding ws) — this absorbs LinkedIn's truncation marker so a `"…"`-suffixed export title still prefix-matches the body. (Mid-word truncation **without** a marker already prefix-matches — `body.startsWith(title)` stays true — so only the marker needs stripping.)
|
||||
3. **`matchRow(record, row)` → `{ confidence: "high"|"low", row } | null`:**
|
||||
- `nb = normalize(record.body)`; `nt` as above.
|
||||
- `if (nt.length < PREFIX_FLOOR) return null` — **`PREFIX_FLOOR = 24`** (plan-critic MINOR 2 — rationale: the hook quality-rule floor is 110 chars and a LinkedIn export title is the content's opening run; 24 normalized chars (~3–5 words) is the shortest opener specific enough that a prefix-match is not coincidental, while staying well under any real hook. Tunable constant with this one-line justification; below floor → `none`).
|
||||
- `if (!nb.startsWith(nt)) return null` → `none`.
|
||||
- else: `record.published_date === row.publishedDate ? "high" : "low"` (prefix match + same date = high; prefix match + different date = low, surfaced for operator eyeball). *(Note the field is `publishedDate` on the analytics side, `published_date` on the record — RISK B.)*
|
||||
4. **`assemblePostGraph({records, analytics})`:** for each record → `{ contentId: record.id, specifics, trends, match }` where `match` = the **best** of `analytics.map(r => matchRow(record, r))`: prefer `high` over `low`; tie-break by **longest matched `nt`** (plan-critic MINOR 1 — to make the tie fully deterministic regardless of input/file order, the assembler **sorts `analytics` once at entry** by `(publishedDate desc, title asc)` before matching, so an exact length tie resolves stably, not by `readdirSync` order); none qualifying → `match: { confidence: "none" }`. Pure — no FS/clock/network; empty `records`→`[]`; empty `analytics`→every post `none` (SC8). The match carries the **whole row reference** (FIX 4).
|
||||
5. **`loadAnalyticsRows(): AnalyticsRowInput[]`** (thin IO, read-only): `const dir = dataRoot("analytics/posts")`; if absent → `[]` (RISK C, fresh-clone). Read each `*.json`, `JSON.parse`, take `.posts` (an `AnalyticsBatch`), map each to the minimal `{ title, publishedDate, metrics }` (extra fields ignored). A malformed/unreadable file is skipped in a try/catch (mirrors `listPublished` `ingest.ts:230`), never a crash. **No import of the analytics package** (§5 decoupling). **Doc-comment caveat (plan-critic MAJOR 2 / R-B):** this resolves the analytics root via the brain's `dataRoot` (`${LINKEDIN_STUDIO_DATA}/analytics/posts`); the analytics package additionally honours the **deprecated `ANALYTICS_ROOT`** env override (`storage.ts:67-72`) which the brain read path does NOT — so if `ANALYTICS_ROOT` is set to a non-default path, `brain assemble` reads the default root and degrades to every-post-`none`. This is the accepted cost of the §5 no-import decoupling (the M0 default leaves `ANALYTICS_ROOT` unset); name it in the loader doc-comment, do not silently skew.
|
||||
|
||||
**GREEN gate (assembler):** `(cd scripts/brain && npm test)` → SC6/SC7/SC8 pass.
|
||||
|
||||
## Step 4 — (GREEN) CLI: repeatable producer flags + read-only assemble
|
||||
|
||||
1. **`collectRepeated(args, key): string[]`** in `cli.ts` (near `parseFlags`): scan the raw `args` for every `--key <value>` pair (value not starting with `--`) and collect all values. **`parseFlags` is left untouched** → single-value flags parse exactly as today (SC12 by construction).
|
||||
2. **`runIngest` signature + call-site change (plan-critic BLOCK 1 — this is a CHANGE, not an existing affordance):** the current code is `function runIngest(flags)` (`cli.ts:92`) dispatched as `runIngest(flags)` (`main`, `cli.ts:238`) — `rest` is NOT passed today. Change BOTH: the signature to `runIngest(rest: string[], flags)` AND the `main` dispatch to `runIngest(rest, flags)`. Then build `specifics = collectRepeated(rest, "specific")`, `trends = collectRepeated(rest, "trend")`, thread into `ingestText({ …, specifics, trends })`. **`runIngest` reads specifics/trends ONLY via `collectRepeated`, never `flags.specific`/`flags.trend`** (R-E). Empty → `[]` (today's behaviour). Update `usage` (`:71`): `ingest --file <path> [--source <s>] [--date <YYYY-MM-DD>] [--specific <id>]… [--trend <id>]…`.
|
||||
3. **`runAssemble(flags)`** + dispatch `if (command === "assemble") return runAssemble(flags);` (`:240`): load **full `PublishedRecord[]`** via the `runConsolidate --gather` record-load idiom (`cli.ts:170-178` — `parsePublishedRecord` over each `dataRoot('ingest/published')/*.md`, NOT `listPublished` which omits `.body` and sorts ascending — plan-critic MAJOR 3) + `analytics = loadAnalyticsRows()`; run `assemblePostGraph(...)`, then **sort the result newest-first explicitly** (`published_date` desc — do not rely on load order) and print: per post `contentId · published_date · <firstLine>` where `firstLine = record.body.split("\n", 1)[0]` (derived locally), then `specifics: …`, `trends: …`, `analytics: <confidence> [eng <engagementRate>%]` (Fork-4: `engagementRate` headline; whole row available for a future `--verbose`). **Writes nothing** (asserted SC9). Missing `ingest/published/` or `analytics/posts/` → clean empty/partial output, no crash (RISK C). Add `assemble` to `usage`.
|
||||
4. **CLI tests** (`cli.test.ts`): SC5 (`--specific a --specific c --trend b` → record tagged `[a,c]`/`[b]` via round-trip read); SC12 (single-value `--file`/`--source`/`--date` + boolean `--scan-inbox`/`--confirm` parse as today, across subcommands); SC9 (`assemble` over a seeded `LINKEDIN_STUDIO_DATA` tmp dir prints the join + writes nothing; over an empty/missing dir prints clean + no crash).
|
||||
|
||||
**GREEN gate:** `(cd scripts/brain && npm test)` → all SCs pass.
|
||||
|
||||
## Step 5 — gate floor + doc reconciliation
|
||||
|
||||
1. `test-runner.sh:716`: `BRAIN_TESTS_FLOOR` 94 → **94 + N** (N = EXACT counted new brain tests from Steps 1–4 — set after counting, never guessed). Extend the breakdown comment `… + SB-S3c N [ingest(a)+assemble(b)+cli(c)]`. **`ASSERT_BASELINE_FLOOR` stays 80** — no new *unconditional structure-lint* check (all new tests are brain-suite tests, counted by `BRAIN_TESTS_FLOOR`; same idiom as S3b §3.7 / SC10).
|
||||
2. `id.ts:6-7`: "SB-S3 will thread this id through the tributaries" → "SB-S3c threads it (post record carries `specifics`/`trends` ids; analytics joined by resolver)".
|
||||
3. `consolidation-loop.md` + `architecture.md:80`: mark the SB-S3 cross-silo graph as landed for the `specific→post→analytics` spine via the hub-side design; note the resolver's **honest limit** (RISK A): the analytics↔post join is a title-prefix+date heuristic with `high/low/none` tiers — a real-CSV `none` is a normalization-tightening signal, not a guarantee of no match (mirror the `consolidation-loop.md` honest-limit idiom).
|
||||
|
||||
## Step 6 — Land
|
||||
|
||||
STATE "Telling" + "👉 NESTE" updated (S3c done → S3d remains, last); brief+plan committed as docs alongside code. **GREEN gate proof:** `(cd scripts/brain && npm test)` = (94+N)/(94+N) and `bash scripts/test-runner.sh` green. Commit (Conventional; code/feat-dominant → `[skip-docs]`). **Push only inside the window** (`date '+%u %H:%M'` first) and **confirm before push** (`origin` is PUBLIC `open/`). No version bump (additive within v0.5.2 dev). Optional documented end-to-end demo (RISK A) recorded in STATE/changelog.
|
||||
|
||||
## Verification (testable)
|
||||
|
||||
| SC | Check | Expected |
|
||||
|----|-------|----------|
|
||||
| SC1 | record round-trip | `parse(serialize(rec))===rec` w/ non-empty specifics/trends; order preserved |
|
||||
| SC2 | byte backward-compat | empty arrays → unchanged 5-line header; `serialize(parse(oldText))===oldText` for fixture old record |
|
||||
| SC3 | id validation | non-12-hex specifics/trends id throws on parse AND at `ingestText` producer boundary |
|
||||
| SC4 | producer threading | `ingestText({specifics:[a],trends:[b]})` → round-tripped `[a]`/`[b]`; absent → `[]` |
|
||||
| SC5 | repeatable flags | `--specific a --specific c --trend b` → record `[a,c]`/`[b]` |
|
||||
| SC6 | assemble high | normalized body starts with row title (≥floor) + same date → `high`, whole row attached, specifics/trends surfaced |
|
||||
| SC7 | assemble low/none | no-prefix → none; `<floor` prefix → none; `…`-truncated title + same date → high (ellipsis stripped); prefix + different date → low |
|
||||
| SC8 | pure + total | `records:[]`→`[]`; `analytics:[]`→every post `none`; no throw |
|
||||
| SC9 | read-only CLI | `brain assemble` prints join newest-first + writes nothing; missing dirs → clean, no crash |
|
||||
| SC10 | gate green | `bash scripts/test-runner.sh` green; brain ≥ new `BRAIN_TESTS_FLOOR`; `ASSERT_BASELINE_FLOOR` unchanged at 80 |
|
||||
| SC11 | no regression | ingest dedup/collision/published-only unchanged |
|
||||
| SC12 | single-value flags | `--file`/`--source`/`--date`/`--scan-inbox`/`--confirm` parse as today |
|
||||
| (red proof) | failing-test-first | brain suite BETWEEN Step 1 and Step 2 → new SCs fail, 94 pass |
|
||||
|
||||
## Risks
|
||||
|
||||
- **R-A (from RISK A) — synthetic-only proof.** All SCs use fixtures; only the §Step-6 demo touches a real CSV. A green gate does **not** prove the resolver matches a real LinkedIn title against a real body. Mitigation: keep the demo; treat a demo `none` as a normalization-tightening signal (honest-limit doc line), not a slice failure. `PREFIX_FLOOR`/ellipsis-strip are the tunables.
|
||||
- **R-B (from RISK B) — analytics extraction-point.** `brain assemble` must inline the raw-JSON read (`dataRoot('analytics/posts')`), **not** `loadAllPosts`, and read **`publishedDate`** (not `date`). Pinned in Step 3.5. A try/catch skips malformed batch files. **Root-skew caveat (plan-critic MAJOR 2):** the brain `dataRoot` ignores the analytics package's deprecated `ANALYTICS_ROOT` override (`storage.ts:67-72`); if set, the join silently degrades to every-post-`none`. Accepted cost of the §5 no-import decoupling (M0 default leaves it unset); named in the `loadAnalyticsRows` doc-comment, not silent.
|
||||
- **R-C (from RISK C) — fresh-clone.** `analytics/posts/` is gitignored/absent on a fresh clone → `loadAnalyticsRows()` returns `[]` → every post `none` (SC8), `brain assemble` degrades cleanly (SC9). Confirm no throw on missing dir.
|
||||
- **R-D — round-trip byte-break.** New header lines MUST be omit-empty + appended after `source:`; an interleaved or always-emitted line breaks SC2 silently. Pinned by the SC2 fixture (byte-equality, not just deep-equal).
|
||||
- **R-E — `collectRepeated` vs `parseFlags` divergence.** `parseFlags` still records the *last* `--specific` value (harmless — `runIngest` ignores `flags.specific`/`flags.trend` and uses `collectRepeated`). Pin: `runIngest` reads specifics/trends ONLY via `collectRepeated`, never `flags`.
|
||||
- **R-F — false-positive match.** A generic short opener could prefix-match the wrong post; `PREFIX_FLOOR=24` + same-date→high (else low) guards it; low matches are surfaced (not hidden) so the operator eyeballs. No silent high on a weak match.
|
||||
- **R-G — floor-count drift (plan-critic MINOR 3).** `BRAIN_TESTS_FLOOR` bumped by the EXACT counted N; the breakdown comment's per-file sub-counts `[ingest(a)+assemble(b)+cli(c)]` MUST sum to N (reconcile after counting). `ASSERT_BASELINE_FLOOR` deliberately unchanged at 80. Verified by the green gate; nothing lints the comment (discipline-only, as S3a/S3b).
|
||||
- **R-H — unguarded doc.** No lint protects `docs/`; reconciled by discipline (as S3a/S3b).
|
||||
Loading…
Add table
Add a link
Reference in a new issue