linkedin-studio/docs/research-engine/plan-re-r2b.md
Kjell Tore Guttormsen 88fbbadb1b docs(linkedin-studio): RE-R2b brief + plan — dated morning-brief artifact + session-start surfacing (light-Voyage hardened, go-gate: wire + English heading)
The visible layer of R2 (R2a landed the data layer @ 7a15803). A pure
rankForBrief (pillar-overlap -> recency over the store, publishedAt ?? capturedAt
freshness, 7d window) + renderBrief (dated Markdown with a hook-surfaceable
summary frontmatter) + briefSummary (one summary source) + defaultBriefDir
(derived from defaultStorePath). CLI `brief` writes the dated file; session-start
surfaces the latest, zero-tsx. Wired into trend-spotter (scan->capture->brief->
surfaced). No store-schema change, no scoring change, no scheduler (R3).

Light-Voyage hardened: scope-guardian ALIGNED (0); brief-reviewer
PROCEED_WITH_RISKS; plan-critic REVISE (2 blockers, 5 majors, 4 minors) — all
folded (briefSummary single source, wrong-value RED stubs, --out !== "true"
guard, defaultBriefDir derives from defaultStorePath, two-gate split since the
hook suite is not in test-runner.sh, url-asc total order). Go-gate: WIRE +
English "## Morning Brief" heading.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmHCQjJHUyWwxGAVVjNLgp
2026-06-24 11:45:20 +02:00

24 KiB
Raw Permalink Blame History

Plan — RE-R2b: dated morning-brief artifact + session-start surfacing

Brief: docs/research-engine/brief-re-r2b.md. Slice: RE-R2b (research-engine rung-2 — R2 visible layer). TDD-order: RED (brief.ts rank/render/path + cli.ts brief tests as logic-RED) → GREEN (brief.ts pure functions) → GREEN (cli.ts brief subcommand) → GREEN (session-start.mjs surfacing + hook test) → wire trend-spotter.md + README → gate floors + Section 16i → behavioural → land. Counts recounted live at land, never pinned/guessed. Light-Voyage hardened: scope-guardian / brief-reviewer / plan-critic findings folded (see §Plan-critic — folded).

Goal

Make the accumulated, publish-dated store visible: a pure rankForBrief (pillar-overlap → recency over the store, with a publishedAt ?? capturedAt freshness window) + a pure renderBrief (a dated Markdown artifact with a hook-surfaceable summary frontmatter), a brief CLI that writes the dated file, and a zero-tsx session-start surfacing of the latest brief. No store schema change; no scoring change; no scheduler — the autonomous trigger + freshness-as-seen-log + relevance scoring stay R3.

Files touched (exhaustive — for scope-guardian)

File Change SC
scripts/trends/src/brief.ts NEW — pure rankForBrief + renderBrief + briefSummary (single summary source) + defaultBriefDir (derived from defaultStorePath) + BriefRanking/BriefEntry types + BRIEF_SCHEMA_VERSION SC1, SC2, SC3
scripts/trends/src/cli.ts EDITbrief subcommand (flag-driven: store→rank→render→write <outDir>/<today>.md; --pillars/--fresh-days/--out/--store/--json; exit 2 bad invocation) + usage()/header doc line + import from brief.js SC4
scripts/trends/tests/brief.test.ts NEWrankForBrief grouping/freshness/sort + renderBrief frontmatter/summary/empty-case/determinism + defaultBriefDir SC1, SC2, SC3
scripts/trends/tests/cli.test.ts EDITbrief happy path (writes dated file, prints path), --json summary, --fresh-days bad → exit 2, empty --pillars → exit 0 no-match brief, --out override SC4
hooks/scripts/session-start.mjs EDIT — module-private latestMorningBrief(briefDir) (zero-tsx readdir+read+extractYaml) + an unconditional ## Morning Brief block after the brain nudge (:500-504), brief dir = join(getDataRoot('trends'),'morning-brief') SC5
hooks/scripts/__tests__/session-start-morning-brief.test.mjs NEW — subprocess + LINKEDIN_STUDIO_DATA fixture (mirrors session-start-trends-staleness.test.mjs): brief present → block surfaces; absent → no block/no crash; zero-tsx SC5
agents/trend-spotter.md EDIT (Open Q#1, default) — after Step 4.5 capture, run brief --pillars <scanned pillars>; carries literal src/cli.ts brief; domain-general SC6
scripts/trends/README.md EDIT — document brief subcommand + trends/morning-brief/YYYY-MM-DD.md artifact + frontmatter shape
scripts/test-runner.sh EDITTRENDS_TESTS_FLOOR 79→recount (inside deps guard) + inline breakdown comment (:697); NEW unconditional Section 16i (after 16h / before 18); ASSERT_BASELINE_FLOOR 90→recount; header-enumeration comment (:33-46) SC6
docs/research-engine/{brief,plan}-re-r2b.md NEW — slice docs (TRACKED, like docs/second-brain/*)
STATE.md EDIT at land — Telling-block reconcile (trends floor, ASSERT floor, hook-suite, gate total). Land bookkeeping, LOCAL-ONLY.

Not touched (scope fence): scripts/trends/src/{types,store,item,score}.ts (no schema change, no scoring change, queryByTopic NOT refactored) · references/* (SSOT unchanged, no new ref) · config/* · no new .mjs under hooks/scripts/ (surfacing edits existing session-start.mjs) · agents/* count (19) · commands/* (29) · references/* (27) · .gitignore (trends lines present).

Step 1 — (RED) failing tests for brief ranking, rendering, and the brief CLI

Author brief.test.ts (new) + extend cli.test.ts. To make the brief cases fail on assertion (not on module-absent — the R2a discipline, plan-re-r2a.md:38-47), Step 1 creates brief.ts with wrong-but-non- throwing stubs: rankForBrief{ today, freshDays: opts?.freshDays ?? 7, totals:{trends:0,matched:0, fresh:0}, topMatches:[], singleMatches:[], olderMatched:[] }; renderBrief""; briefSummary""; defaultBriefDir"". Cases then fail on value assertions (empty buckets ≠ expected, "" lacks frontmatter), not module-not-found. The stubs are replaced in Step 2 — none survives into GREEN.

brief.test.ts:

  • rank/group (SC1): a fixture store with trends at known overlap (0/1/2+) × freshness → topMatches only overlap≥2 & fresh, singleMatches only overlap===1 & fresh, olderMatched only overlap≥1 & stale, overlap===0 absent everywhere; within-group order overlap desc → effectiveDate desc → title asc → url asc; matchedPillars = actual matched names (case-insensitive match, pillar original case kept); totals correct. Total-order fixture: two records with the same title + effectiveDate + overlap but different url → assert a fixed order by url asc (proves order is independent of store insertion / V8 sort stability).
  • freshness (SC2): effectiveDate = publishedAt ?? capturedAt; fresh-by-publishedAt-despite-old-capturedAt and fresh-by-capturedAt-fallback-when-publishedAt-absent; boundary ageDays === freshDays is fresh; freshDays: 14 re-buckets a 10-day item from older→fresh.
  • render + summary (SC3): renderBrief output starts with frontmatter carrying date / a column-0 single-line summary (assert no " and no \n in the summary line) / store: { trends, matched, fresh } / schemaVersion: 1; the frontmatter summary equals briefSummary(ranking) (one source); body has the three section headings + the pinned entry-line shape; the empty ranking → a valid brief whose summary is briefSummary(emptyRanking) = the "no fresh" line; determinism: two renderBrief calls on the same ranking are byte-identical.
  • path: defaultBriefDir() ends with trends/morning-brief and honors LINKEDIN_STUDIO_DATA (set the env in-test, assert prefix == that root; restore) — proving it rides on defaultStorePath's root.

cli.test.ts (subprocess node --import tsx src/cli.ts brief with a --store temp + --out temp):

  • happy: --pillars a,b --store <tmp-with-matches> --out <tmp> writes <out>/<today>.md (file exists; today-shaped name) and prints the path; --json → object with path/date/totals/summary, summary equal to what the file's frontmatter carries.
  • --fresh-days xyz → exit 2; empty --pillars (omit the flag) → exit 0 + a file written (no-match brief); bare --out (no value) → does NOT write to ./true/ (falls back to defaultBriefDir() — the flags.out !== "true" guard).

RED proof (record in commit): (cd scripts/trends && npm test)brief.test.ts cases fail on assertion (wrong-value stubs), and cli.test.ts brief cases fail logic-RED against the existing dispatch (an unknown brief command hits usage() → exit 2, no file written) — neither is module-not-found.

Step 2 — (GREEN) pure brief layer: brief.ts

Create scripts/trends/src/brief.ts:

  • import type { TrendStore, TrendRecord } from "./types.js";
  • Types: BriefEntry { trend: TrendRecord; overlap: number; matchedPillars: string[]; effectiveDate: string; ageDays: number }; BriefRanking { today: string; freshDays: number; totals: { trends: number; matched: number; fresh: number }; topMatches: BriefEntry[]; singleMatches: BriefEntry[]; olderMatched: BriefEntry[] }.
  • export const BRIEF_SCHEMA_VERSION = 1;
  • import { defaultStorePath } from "./store.js"; + import { join, dirname } from "node:path"; (NO homedir — the root comes via defaultStorePath, not re-resolved).
  • rankForBrief(store, pillars, today, opts): freshDays = opts?.freshDays ?? 7; lowercase the pillar set once; for each trend compute overlap/matchedPillars (case-insensitive membership, original-case pillar preserved — same idiom as store.ts:151-152, recomputed here, queryByTopic untouched), effectiveDate = trend.publishedAt ?? trend.capturedAt, ageDays = Math.floor((Date.parse(today) - Date.parse(effectiveDate)) / 86400000) (a local day-delta — NOT imported from cli.ts's daysBetween cli.ts:107-109; importing it would invert the dependency direction, as cli.ts imports brief.ts). Bucket; sort each bucket overlap desc → effectiveDate desc → title asc → url asc (total order); totals.
  • briefSummary(ranking): the single summary source — fresh>0 ? <fresh> ferske tema-signaler matcher pillarene dine. Topp: «<top title>» (<top pillar> · <age>d). : Ingen ferske tema-signaler på pillarene dine (av <trends> i lager). One line, no ", no \n.
  • renderBrief(ranking): build the YAML frontmatter with summary: ${briefSummary(ranking)} (the shared source, never re-derived), date/store/ranking/schemaVersion, then the three sections with the pinned entry-line shape (titles in «»). Column-0 summary.
  • defaultBriefDir(): join(dirname(defaultStorePath()), "morning-brief")defaultStorePath() = <root>/trends/trends.json, so dirname = <root>/trends, + morning-brief = <root>/trends/morning-brief. One root resolution (reuses defaultStorePath); no duplicate LINKEDIN_STUDIO_DATA ?? join(homedir()…).
  • Replace the Step-1 wrong-value stubs. Make Step 1's brief.test.ts cases green.

Step 3 — (GREEN) CLI brief + cli.test.ts

In cli.ts: add import { rankForBrief, renderBrief, briefSummary, defaultBriefDir } from "./brief.js";, add mkdirSync/writeFileSync to the node:fs import (currently only readFileSync cli.ts:30), and add import { join } from "node:path";. Add the brief branch to main's dispatch (after capture): const pillars = splitTopics(flags.pillars); parse --fresh-days (default 7; Number.isNaNusage("--fresh-days must be a number")); const outDir = flags.out && flags.out !== "true" ? flags.out : defaultBriefDir() (the !== "true" guard is required — parseFlags cli.ts:52-53 yields "true" for a bare --out); const ranking = rankForBrief(loadStore(storePath), pillars, today(), { freshDays }); const md = renderBrief(ranking); const path = join(outDir, today() + ".md"); mkdirSync(outDir, { recursive: true }); writeFileSync(path, md, "utf8"); human → Wrote brief: <path> (M matched, K fresh); --json{ path, date: ranking.today, totals: ranking.totals, summary: briefSummary(ranking) }summary is briefSummary(ranking), the same source renderBrief puts in the frontmatter (no re-derivation). Add the brief … line to usage() (cli.ts:71-84) and the header doc-comment (cli.ts:1-28). Make Step 1's cli.test.ts brief cases green. today() exact-value assertions are NOT in cli.test.ts (it reads the wall clock) — the date-shape (/^\d{4}-\d{2}-\d{2}$/) is asserted on the written filename; exact-date logic is covered in brief.test.ts via injected today.

Step 4 — (GREEN) session-start surfacing + hook test

session-start.mjs: add latestMorningBrief(briefDir) near trendsNewestCapture (:38): existsSync guard → readdirSync(briefDir).filter(f => /^\d{4}-\d{2}-\d{2}\.md$/.test(f)).sort().reverse()[0] → read it → { date: extractYaml(c,'date'), summary: extractYaml(c,'summary'), file: join(briefDir, name) }; any throw/absence → null. extractYaml (:19-23) captures [^"\n]* + .trim(), so date/summary are newline-free → the surfaced block needs no .replace(/\n/g,'\\n') (contrast the multi-line state sections :320); only the static \\n literals are used. Inject after the brain-missing nudge (:500-504), unconditional:

const latestBrief = latestMorningBrief(join(getDataRoot('trends'), 'morning-brief'));
if (latestBrief && latestBrief.summary) {
  context += `\\n## Morning Brief (${latestBrief.date})\\n${latestBrief.summary}\\n→ Full brief: ${latestBrief.file}\\n`;
}

(getDataRoot :11 + join :6 are already imported; readdirSync already imported :5.) No tsx. Then session-start-morning-brief.test.mjs (new): read session-start-trends-staleness.test.mjs first and copy its exact structure (the env-var name it sets, how it spawns session-start.mjs, how it parses the JSON hookSpecificOutput.additionalContext, how it writes the fixture under the temp root). Cases: (a) with a fixture trends/morning-brief/<date>.md under env.LINKEDIN_STUDIO_DATAadditionalContext includes ## Morning Brief, the summary, the file path, and no raw \n inside that block (the \\n idiom held); (b) no brief dir → no Morning Brief block + continue: true (no crash); (c) path cross-check — write the fixture at the path defaultBriefDir() resolves to under that same LINKEDIN_STUDIO_DATA and confirm the hook finds it (CLI-write path == hook-read path). The temp root has no node_modules/tsx (zero-dep proof). This test runs under node --test hooks/scripts/__tests__/, the separate hook-suite gate — NOT test-runner.sh.

Step 5 — wire trend-spotter.md (Open Q#1, default) + README

In agents/trend-spotter.md, after the Step 4.5 capture block, add a step: build the pillar list the agent already scans and run node --import tsx scripts/trends/src/cli.ts brief --pillars <pillars> to write today's dated brief. Replacement prose must contain the literal src/cli.ts brief (Section 16i grep -qF). Keep the "skip silently if no deps" escape hatch + domain-general phrasing (no vendor/sector tokens — Section 17). Update scripts/trends/README.md: a brief CLI example + the trends/morning-brief/YYYY-MM-DD.md artifact + its frontmatter (date/summary/store/schemaVersion), framed as "the dated, surfaced read over the store" (distinct from query/list interactive dumps).

Step 6 — gate: floors + new unconditional Section 16i

In scripts/test-runner.sh:

  • Set TRENDS_TESTS_FLOOR (:697, currently 79) to the tests N line reported by (cd scripts/trends && npm test) after Steps 13 — recounted live, NOT an additive guess. Stays inside the if [ -x …/tsx ] deps guard. Append + RE-R2b: brief +N + cli +M (morning-brief) to the inline per-slice breakdown comment on :697 so the comment can't drift from the number.
  • Add Section 16i ("Trends Brief Wiring", RE-R2b), mirroring Section 16h's shape (:1074-1116). Placement (verified): file order is 17→16g→16h→18 (:943/:1010/:1074/:1118), so 16h is the last section before Section 18 — insert 16i after 16h's closing fi/echo "" (~:1116), before the Section 18 block (:1118) (anti-erosion Section 18 must stay last so it counts every prior check). Four unconditional, deps-absent-safe checks (pure grep -qF/self-test, no tsx): (1) a non-vacuity self-test (probe carrying src/cli.ts brief accepted, one without rejected); (2) grep -qF 'command === "brief"' scripts/trends/src/cli.ts; (3) grep -qF 'src/cli.ts brief' agents/trend-spotter.md; (4) grep -qF 'latestMorningBrief' hooks/scripts/session-start.mjs (surfacing is wired, not just doc'd).
  • Bump ASSERT_BASELINE_FLOOR (:1136, currently 90) → live recount (= 90 + the new unconditional 16i emitters; expected ~94, recounted at land). Update the header-enumeration prose chain (:33-46) by inserting the 16i clause between the 16h clause (:43-45) and the Section-18 clause (:46), preserving sentence flow (it's prose, not an append).
  • NOT touched here: the hook suite has no floor in test-runner.sh (no HOOK_TESTS_FLOOR, no root package.json); the new hook test is gated by node --test hooks/scripts/__tests__/ (Step 4), separately.

Step 7 — behavioural verification

(cd scripts/trends && npm install) if needed, then run brief §7's three behavioural steps (seed via capture, generate via brief --json, surface via session-start.mjs with LINKEDIN_STUDIO_DATA). Confirm the top-match grouping, the summary, and the surfaced block (tsx absent for the surfacing). Run full bash scripts/test-runner.shFAIL=0.

Step 8 — land

Recount all touched floors live; reconcile STATE.md "Telling" block (trends N/N, ASSERT floor, hook-suite, gate total). Commit order (house style): (1) docs commit docs/research-engine/{brief,plan}-re-r2b.md (no suffix, tracked); (2) code commit scripts/trends/src/brief.ts + cli.ts + tests + hooks/scripts/session-start.mjs + the new hook test + agents/trend-spotter.md + scripts/trends/README.md

  • scripts/test-runner.sh with [skip-docs]. Push freely (window lifted; gitleaks at commit; origin = PUBLIC open/ — STATE/*.local.* never pushed). No version bump (additive; v0.5.2 dev).

Verification (testable)

SC Check Command Expected
RED proof (cd scripts/trends && npm test) after Step 1 brief cases fail on assertion (logic-RED), not module-not-found
SC1 rank/group npm test (brief.test.ts) groups by overlap×freshness; overlap-0 excluded; sort overlap→effectiveDate→title; matchedPillars + totals correct
SC2 freshness npm test (brief.test.ts) effectiveDate = publishedAt ?? capturedAt; boundary =freshDays fresh; freshDays configurable
SC3 render npm test (brief.test.ts) frontmatter date/summary(no ")/store/schemaVersion:1; 3 sections; empty→valid no-match brief; deterministic bytes
SC4 CLI brief npm test (cli.test.ts) + manual writes <out>/<today>.md; --json {path,date,totals,summary}; --fresh-days bad → exit 2; empty --pillars → exit 0 no-match
SC5 surfacing node --test hooks/scripts/__tests__/ + manual brief present → ## Morning Brief + summary + path, no raw \n; absent → no block/no crash; zero-tsx; CLI-write path == hook-read path
SC6(a) gate + wiring + de-niche bash scripts/test-runner.sh FAIL=0; trends ≥ floor; Section 16i green; ASSERT floor recount; Section 17; counts 27/19/29
SC6(b) hook gate node --test hooks/scripts/__tests__/ new session-start-morning-brief.test.mjs green (hook suite is NOT part of test-runner.sh)

Risks

  • R1 — non-determinism leaks into the brief (clock/env inside the pure functions → flaky bytes). Mitigated: today/pillars/freshDays injected; SC3 asserts byte-identical output; ageDays uses the injected today.
  • R2 — extractYaml mis-reads the summary (a " or newline in the value truncates it). Mitigated: renderBrief emits a single-line summary with no embedded " (titles in «»); SC3 asserts it; the hook guards latestBrief.summary truthy before surfacing.
  • R3 — the hook accidentally needs tsx (e.g. someone "reuses" the TS ranker in the hook). Mitigated: surfacing is readdir+readFile+extractYaml only; SC5 runs with tsx absent; the B-S3 precedent (:32-37) is the explicit pattern.
  • R4 — editing trend-spotter.md trips the de-niche guard. Mitigated: Section 17 runs in the gate; the added step is pillar-driven (user config), vendor/sector-free; only a brief call is added.
  • R5 — new gate checks must survive a deps-absent fresh clone. Mitigated: Section 16i is pure grep/self-test on tracked source (no tsx) → unconditional; TRENDS_TESTS_FLOOR stays inside the deps guard.
  • R6 — brief dir vs store path drift (hook join(getDataRoot('trends'),'morning-brief') vs the CLI's defaultBriefDir()). Mitigated: defaultBriefDir() now derives from defaultStorePath() (one root resolution, not a re-implementation) — so brief.ts and store.ts cannot disagree on the root. The remaining hook-vs-CLI pair (getDataRoot('trends') vs defaultStorePath's root) is the same store-path twin already in production (the B-S3 staleness nudge reads join(getDataRoot('trends'),'trends.json') :376 and must already equal the CLI's defaultStorePath() for that nudge to work) and is behaviorally guarded by __tests__/data-root.test.mjs. SC5(c) adds a direct write-then-read cross-check; brief.test pins defaultBriefDir against LINKEDIN_STUDIO_DATA.
  • R7 — brief.ts is a new source file (R2a forbade one for the bridge). Mitigated: justified by cohesion (a ranking+rendering module with its own types/version, not a single-use function); §5 states it; counts unaffected (no new ref/agent/command).

Plan-critic — folded

plan-critic returned REVISE (2 blockers, 5 majors, 4 minors); brief-reviewer PROCEED_WITH_RISKS (1 major + minors); scope-guardian ALIGNED (0 findings; counts 27/19/29 verified live). Resolution, each verified against live code:

  • [BLOCKER] --json summary source left as "re-derive OR expose" — RED can't target an undecided API. committed to export function briefSummary(ranking): string (Step 2); renderBrief frontmatter + CLI --json both call it (Step 3); Step 1 asserts renderBrief's frontmatter summary === briefSummary(ranking).
  • [BLOCKER] Section 16i placement ambiguous ("after 16h / before 18" vs brief's wording). verified file order 17→16g→16h→18 (:943/:1010/:1074/:1118); Step 6 pins 16i after 16h's closing echo "" (~:1116), before Section 18 (:1118); states 16h is the last section before 18.
  • [MAJOR] throwing-stub RED contradicts "fail on assertion, not module-not-found" (R2a's blocker class). Step 1 now uses wrong-but-non-throwing stubs (empty buckets / "") so brief.test fails on assertion; cli.test brief cases are logic-RED against the existing dispatch (unknown command → usage() exit 2).
  • [MAJOR] brief §3 flags.out ?? defaultBriefDir() writes ./true/ for a bare --out (parseFlags cli.ts:52-53). brief §3 + Step 3 use the flags.out !== "true" guard; Step 1 adds a bare---out cli.test case.
  • [MAJOR] TRENDS_TESTS_FLOOR recount ambiguous (full tests N vs additive). Step 6 pins it to the tests N line after Steps 13, comment appends + RE-R2b: brief +N + cli +M.
  • [MAJOR] defaultBriefDir triple-twin root re-resolution (drift risk). it now derives from defaultStorePath() (one root resolution, Step 2); R6 + SC5(c) cite the existing data-root.test.mjs twin guard and add a write-then-read cross-check.
  • [MAJOR] surfacing newline-escape unproven. Step 4 + SC5 state extractYaml's [^"\n]* + .trim() makes date/summary newline-free → no .replace needed; SC5 asserts no raw \n in the block.
  • [brief-reviewer MAJOR] SC6 "hook-suite at recount" attributed to test-runner.sh (which neither runs nor counts it — no HOOK_TESTS_FLOOR, no root package.json). SC6 + the verification table split into two gate commands: test-runner.sh (trends/16i/ASSERT/de-niche/counts) and node --test hooks/scripts/__tests__/ (the new hook test).
  • [MINOR] non-total sort order (same title, different url ties fully). added url asc final tie-break (Step 2/SC1); Step 1 adds a same-title/diff-url fixture. [MINOR] local ageDays duplication unstated Step 2 states the deliberate non-import (dependency direction cli.ts → brief.ts). [MINOR] .md$ anchor confirmed in the filter regex (Step 4). [MINOR] header-enumeration prose flow Step 6 inserts the 16i clause in-prose between the 16h and Section-18 clauses. [brief-reviewer MINOR] body entry-line age format pinned in brief §3 + SC3. [brief-reviewer MINOR] Open Q#2/#4 are load-bearing reframed as confirmations (brief §8).
  • [plan-critic headless-readiness] N/A — R2b executes in-session, operator-driven (driftsmodell), not as a headless autonomous run, so per-step revert/halt clauses aren't needed (R1/R2a had none either).

scope-guardian — ALIGNED: every SC1SC6 traces to a step; zero creep; all nine §4 non-goals held (no scheduler, no scoring change, no store-schema change, no relevance/saturation/status fields, no pillar-from-state resolution, no delivery channel, no brief-diffing, no new agent/command); counts 27/19/29 verified live; brief.ts the only new source file.