diff --git a/docs/research-engine/brief-re-r2b.md b/docs/research-engine/brief-re-r2b.md new file mode 100644 index 0000000..4eefbe2 --- /dev/null +++ b/docs/research-engine/brief-re-r2b.md @@ -0,0 +1,309 @@ +# Brief — RE-R2b: dated morning-brief artifact + session-start surfacing + +> **Slice:** RE-R2b (research-engine rung-2, slice 2 — the *visible* layer of R2). R2 ("the visible +> topic-stream") was split at the 2026-06-24 go-gate into **R2a** (the pure `scripts/trends/` data layer — +> capture bridge + `publishedAt` persistence, landed `7a15803`) and **R2b** (this — the dated morning-brief +> artifact + session-start surfacing). R2a delivered **no visible change**; it closed the capture loop so the +> store now accumulates publish-dated history. R2b makes that stream visible: a deterministic, dated Markdown +> brief ranked over the store, surfaced at session-start. +> **Predecessor:** RE-R2a (`brief-re-r2a.md`) persisted `publishedAt` first-sight and shipped the `capture` +> CLI; it **explicitly deferred** the dated brief artifact (B3) and session-start surfacing (hull 4) to R2b +> (`brief-re-r2a.md` §4). R2b builds exactly those two deferred pieces. +> **Substrate:** `docs/research-engine-concepts.local.md` §3 **B3** (dated-digest as a flat plain-text +> artifact: `YYYY-MM-DD.md`, diffbar/grep-bar/lastbar; delivery is a separate later step) + **hull 4** +> (surfacing at session-start) + §1 hull list (2)(4)(7). **TDD-order:** RED tests land before code. + +## 1. Operator decision context (2026-06-24) + +The research engine is **Tier-1** (operator, 2026-06-23): *"hele min arbeidsflyt hviler på at jeg får en jevn +strøm av gode forslag til tema å skrive om."* At the R2 go-gate the operator chose foundation-first: R2a (the +bridge + schema) before R2b (the visible brief + surfacing). **R2b is the slice that delivers the visible +value** — the operator opens a session and *sees* a dated brief of fresh, on-pillar topic signals, without +running anything. The autonomous nightly trigger that would *regenerate* it unattended stays R3 (hull 1); in +R2b the brief is produced **on demand** (a trend scan, or the `brief` CLI) and **surfaced** whenever one +exists. Confirmed at format sign-off (2026-06-24): **D1** on-demand generation + session-start surfaces the +latest (no tsx in the hook to regenerate); **D2** deterministic ranking on pillar-overlap → recency (no AI / +no `score.ts` in the brief path — scores aren't persisted yet); **D3** freshness window = 7 days. + +## 2. The gap — grounded in code + +- **The store accumulates publish-dated history nobody reads.** Post-R2a, `TrendRecord` carries + `publishedAt?` (`types.ts:43`) and the `capture` CLI folds polled trends in (`cli.ts:237-263`), but **no + artifact ranks or presents the accumulated store.** The read surfaces today are `query` (topic-scoped, + `cli.ts:143-164`) and `list` (time-scoped, `cli.ts:166-185`) — both are interactive CLI dumps, neither is a + persisted, dated, surfaced brief. The "morgen-brief" rung the engine is aimed at (substrate §1, hull 2) does + not exist. +- **`publishedAt` is persisted but never ranked on.** `queryByTopic` ranks `topicOverlap desc → capturedAt + desc` (`store.ts:155-157`) — it sorts on *when WE saw it*, never on *when the source published*. So even the + freshest source item is ordered by capture time, not publish time. The field R2a persisted specifically for + freshness ranking has no reader. R2b's `rankForBrief` is that reader. +- **Session-start surfaces staleness, not signal.** The SessionStart hook already reads the store directly + (zero-tsx) for the B-S3 staleness *nudge* (`session-start.mjs:38-52, 376-380`) — "trend signals are N days + old, scan to refresh." It tells the operator the store is *stale*; it never tells them *what is in it*. + Hull 4 (surfacing the actual brief) is unbuilt — the seam (a direct store/data-dir read in the hook) is + already proven and reusable. + +## 3. Scope — what is IN (RE-R2b) + +### B-rank + B-render — `scripts/trends/src/brief.ts` (NEW `.ts`) +Two **pure** functions + the brief's own format-version const + its result types. A new module is justified +(cohesive brief layer — ranking + rendering + types — not a single-use function; contrast R2a's `itemToInput`, +which belonged inside `item.ts`). No fs, no clock, no AI: `today` and `pillars` are injected by the caller. + +- `rankForBrief(store: TrendStore, pillars: string[], today: string, opts?: { freshDays?: number }): BriefRanking` + — for each trend computes `overlap` (count of `pillars` present in `trend.topics`, **case-insensitive, the + same idiom as `queryByTopic` `store.ts:151-152`** — computed inline, `queryByTopic` is NOT refactored), + `matchedPillars` (the actual matched names), `effectiveDate = publishedAt ?? capturedAt`, and + `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` — that would invert the dependency direction, + since `cli.ts` imports `brief.ts`, not the reverse). Groups: **`topMatches`** (`overlap ≥ 2` AND `ageDays ≤ + freshDays`), **`singleMatches`** (`overlap === 1` AND fresh), **`olderMatched`** (`overlap ≥ 1` AND NOT + fresh). `overlap === 0` is **excluded entirely** (off-pillar noise). Within each group: sort `overlap desc`, + then `effectiveDate desc` (freshest first), then `title asc`, then **`url asc`** — a **total order** (two + records can share title+effectiveDate+overlap but never title+url, since title+url is the dedupe id + `store.ts:66-68`; the `url` key makes order independent of store insertion / V8 sort stability). Returns + `{ today, freshDays, totals: { trends, matched, fresh }, topMatches, singleMatches, olderMatched }`. + `freshDays` default **7** (D3). +- `renderBrief(ranking: BriefRanking): string` — produces the full Markdown artifact: a YAML frontmatter block + (`date`, a one-line **`summary`** the hook surfaces verbatim, `store: { trends, matched, fresh }`, a + `ranking:` descriptor, `schemaVersion: `) + the body. **Body entry line (pinned):** + `### . ` then ``- Kilde: <source> · Publisert: <effectiveDate> (<ageDays>d) · Pillarer: <matched, + joined> `` then optional summary then `🔗 <url>` (single-matches/older render as one-line bullets: + `- <title> — «<pillar>» · <effectiveDate> (<ageDays>d) · 🔗 <url>`). The empty case (no fresh matches) still + renders a valid brief with a "no fresh on-pillar signals" `summary`. The frontmatter `summary` is produced by + the shared **`briefSummary(ranking)`** (below), NOT re-derived — one source for the frontmatter line and the + CLI `--json`. **Deterministic:** same `(store, pillars, today, freshDays)` → byte-identical output (total-order + sort, no clock/env inside the pure functions). +- `briefSummary(ranking: BriefRanking): string` — the **single** source of the one-line summary, used by + `renderBrief` (frontmatter) AND the CLI `--json`. Output is a **single line, column-0 in frontmatter, with no + embedded `"` and no newline** (titles in «», fields separated by `·`) — so the hook's `extractYaml` regex + (`^summary: *"?([^"\n]*)"?`, `session-start.mjs:20`) captures it whole. Fresh>0 → + `<fresh> ferske tema-signaler matcher pillarene dine. Topp: «<top title>» (<top pillar> · <age>d).`; + else → `Ingen ferske tema-signaler på pillarene dine (av <trends> i lager).` +- `defaultBriefDir(): string` — **derives from `defaultStorePath()`** (`store.ts:190-193`): `join(dirname( + defaultStorePath()), "morning-brief")` → `<root>/trends/morning-brief`. **One** root resolution (reuses + `defaultStorePath`, imported from `store.js`); no independent re-resolution of `LINKEDIN_STUDIO_DATA` in + `brief.ts` (M4 — kills the duplication the first draft introduced). Colocated with the store the brief reads + (Open Q#2). Pure path computation, no fs. +- `BRIEF_SCHEMA_VERSION = 1` — the artifact format version (distinct from the store's `SCHEMA_VERSION`). + +### CLI `brief` subcommand (`scripts/trends/src/cli.ts`, EDIT) +`node --import tsx src/cli.ts brief [--pillars <a,b,c>] [--fresh-days <N>] [--store <path>] [--out <dir>] +[--json]` — flag-driven (reads the **store**, not stdin — unlike `normalize`/`score`/`capture`). Resolves +`pillars = splitTopics(flags.pillars)` (the caller supplies the user's pillars — same edge-injection pattern as +`capture` injecting `today()`; resolving pillars from the profile is a thin caller concern, §4), `freshDays` +from `--fresh-days` (default 7; non-numeric → `usage()`→exit 2, mirroring `--limit`/`--threshold`), `store = +loadStore(storePath)`, `outDir = flags.out && flags.out !== "true" ? flags.out : defaultBriefDir()` (the +`!== "true"` guard is **required** — a bare `--out` with no value yields the string `"true"` via `parseFlags` +`cli.ts:52-53`, so `flags.out ?? …` would write to `./true/`). Runs `rankForBrief(store, pillars, today(), +{ freshDays })` → `renderBrief(ranking)` → writes `<outDir>/<today()>.md` (`mkdirSync({recursive})` + +`writeFileSync` at the CLI edge — `brief.ts` stays pure). Human output: the written path + `(M matched, K +fresh)`; `--json` emits `{ path, date, totals, summary }` where `summary = briefSummary(ranking)` (the **same** +source as the frontmatter). Exit **2** only on malformed invocation; **0** +otherwise, **including empty `--pillars`** (writes a valid no-match brief — the operator who hasn't set pillars +still gets a dated, surfaceable file telling them to set pillars). Add a `brief …` line to `usage()` and the +header doc comment. + +### Session-start surfacing (`hooks/scripts/session-start.mjs`, EDIT — zero-tsx) +A module-private `latestMorningBrief(briefDir)` (mirroring `trendsNewestCapture` `session-start.mjs:38-52` and +`brainLastRun` `:57-65`): absent dir → `null`; else `readdirSync` → keep `/^\d{4}-\d{2}-\d{2}\.md$/` → sort +desc → read the newest → extract `date` + `summary` via the existing `extractYaml` (`:19-23`) → return +`{ date, summary, file }` (or `null` on any read failure — never throws). `extractYaml`'s capture is +`[^"\n]*` + `.trim()` (`:20-23`), so the extracted `date`/`summary` are **newline-free by construction** → +the surfaced block needs **no** `.replace(/\n/g,'\\n')` treatment (unlike the multi-line state sections +`:320`); the static structure uses the literal `\\n` idiom (`:309-321`). **Pure fs + regex; never spawns +`tsx`** (the analytics fresh-clone-crash invariant; the store schema/brief frontmatter are stable, so a direct +read is safe — identical reasoning to the B-S3 comment `:32-37`). Injected as its own block **after the +brain-missing nudge (`:500-504`)**, unconditional on a brief existing (so it surfaces on the fresh-install +branch too, like the brain nudge): `## Morning Brief (<date>)` + the `summary` + `→ Full brief: <file>`. The +brief dir is `join(getDataRoot('trends'), 'morning-brief')` — the **twin** of `defaultBriefDir()` (same +established pattern as the store-path twin, `:376`). Heading kept English to match the existing 14 hook +sections (Open Q#3); the `summary` body stays Norwegian (operator-facing). + +### Wiring + gate (Open Q#1 — WIRE by default, mirrors R2a Open Q#1) +- `agents/trend-spotter.md` (EDIT): after the Step 4.5 `capture` (re-pointed in R2a), add a step that runs + **`node --import tsx scripts/trends/src/cli.ts brief --pillars <the pillars the agent already scans>`** so a + trend scan *produces* today's dated brief — closing the visible loop **scan → capture → brief → surfaced next + session**. Replacement prose carries the literal `src/cli.ts brief`. Domain-general (pillars are the user's + config; no vendor/sector tokens). Keep the "skip silently if no deps" escape hatch. +- `scripts/trends/README.md` (EDIT): document the `brief` subcommand + the `trends/morning-brief/YYYY-MM-DD.md` + artifact + its frontmatter shape (honest CLI/artifact doc). +- `scripts/test-runner.sh` (EDIT): bump `TRENDS_TESTS_FLOOR` — set it to the `tests N` line reported by + `(cd scripts/trends && npm test)` after Steps 1–3 (it stays **inside** the deps guard; 79 today, `store.ts` + comment `:697` is per-slice), and **append** `+ RE-R2b: brief +N + cli +M (morning-brief)` to that inline + breakdown comment so number and comment can't drift. Add **Section 16i** ("Trends Brief Wiring"): insert it + **immediately after Section 16h** (currently the **last** section before Section 18 — file order is 17→16g→ + 16h→18, `:943/:1010/:1074/:1118`), i.e. after 16h's closing `fi`/`echo ""` (~`:1116`), **before** the Section + 18 block (`:1118`). Mirror 16h's shape: **unconditional**, deps-absent-safe `grep -qF` + a non-vacuity + self-test — (1) self-test; (2) `command === "brief"` in `cli.ts`; (3) `src/cli.ts brief` in + `agents/trend-spotter.md`; (4) `latestMorningBrief` in `session-start.mjs` (surfacing is wired, not merely + documented). These are unconditional → bump `ASSERT_BASELINE_FLOOR` 90 → **live recount** (expected ~94). + 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. +- **Hook suite (a SEPARATE gate, not run by `test-runner.sh`):** new + `hooks/scripts/__tests__/session-start-morning-brief.test.mjs` (mirrors + `session-start-trends-staleness.test.mjs`: subprocess + `LINKEDIN_STUDIO_DATA` fixture), green under + `node --test hooks/scripts/__tests__/` (the command that runs the 136-test hook suite). `test-runner.sh` has + no `HOOK_TESTS_FLOOR` and no root `package.json` — so SC6 asserts the hook test under its **own** command, + never as part of `bash scripts/test-runner.sh`. + +## 4. Non-goals — what is OUT (deferred) + +- **Autonomous nightly trigger** (cron/launchd, hull 1) — **R3**. R2b's brief is generated on demand (a scan / + the CLI); the hook only *surfaces* the latest. *No scheduler enters the repo in R2b.* +- **Freshness as a persisted seen-log / dedup-vs-seen (B4)** — R3. R2b's freshness is a **read-time filter** + (`effectiveDate ≤ freshDays` at rank time), not an append-only seen-log. +- **Relevance / saturation / status / first-mover scoring fields (hull 5)** — R3. R2b ranks on **pillar-overlap + + recency only**; the B2 triage scorer (`score.ts`) stays **out of the brief path** (its output isn't + persisted on records yet — that's R3). No AI in the brief path (D2). +- **Research-deepening (A1–A4), adapter sub-agents, MCP fetch fan-out** — R3. +- **Pillar resolution from the state file** (`expertise_areas`) — OUT; pillars arrive via `--pillars` (the + agent/caller supplies them). Wiring state→pillars is a thin follow-up, not this slice. +- **Brief history surfacing / diffing ("yesterday vs today")** — OUT. The artifact is dated and accumulates on + disk (hull 7 storage is satisfied), but R2b surfaces only the **latest**; diffing is later. +- **Delivery (Slack/email)** — OUT. B3 keeps delivery a separate step; R2b's only "delivery" is session-start + surfacing. No delivery channel, no push-window guard (that mechanism is R3/B4). +- **Store schema change** — none. R2b is **read-only** over the store; `types.ts`/`store.ts` record shape and + `SCHEMA_VERSION` (2) are untouched (only a pure `defaultBriefDir` path helper is added, in `brief.ts`). +- **No new agent/command** — counts stay 19/29/27. `brief` is a CLI subcommand; surfacing is a hook edit; + generation is wired into the existing `trend-spotter`. + +## 5. Boundaries / invariants (must hold) + +- **TDD iron law:** the failing `brief.ts` tests (`rankForBrief` grouping/freshness/sort + `renderBrief` + frontmatter/`summary`/empty-case + `defaultBriefDir`) and the `cli.ts brief` tests land **BEFORE** the + implementation. RED is logic-RED (throwing stub for the not-yet-existing exports so cases fail on assertion, + not module-not-found). +- **Purity:** `rankForBrief`/`renderBrief`/`defaultBriefDir` touch no fs, no clock, no env-at-call, no AI — + `today`/`pillars`/`freshDays` are injected. All fs lives at the CLI edge (`cli.ts`) and in the hook. +- **Determinism:** same `(store, pillars, today, freshDays)` → byte-identical brief (stable three-key sort). +- **Hook stays tsx-free:** surfacing is a plain `readdir` + `readFile` + `extractYaml` (the established + zero-dep pattern); it **never** shells out to `tsx` (analytics fresh-clone-crash invariant). A fixture run + with no `node_modules/tsx` in `scripts/trends` must still surface the brief (SC5). +- **Domain-general:** Section 17 de-niche guard stays green; the `trend-spotter.md` edit + the brief artifact + carry **no vendor/sector tokens**; pillars are the user's config, never hardcoded. +- **No scoring change:** `score.ts` + `references/trend-scoring-modes.md` (SSOT) untouched. +- **No store schema change:** `types.ts`/`store.ts` record shape + `SCHEMA_VERSION` unchanged; `queryByTopic` + NOT refactored (overlap is recomputed in `brief.ts`). +- **Pathguard:** `brief.ts` is a NEW `.ts` (allowed); **NO new `.mjs` under `hooks/scripts/`** (surfacing edits + the existing `session-start.mjs`); `.gitignore` already covers `scripts/trends/{node_modules,build}`. +- **House conventions:** ESM + `node:test` + `tsx`; brief+plan docs live under `docs/` (uncounted, TRACKED like + `docs/second-brain/*`). +- **Counts** (refs/agents/commands 27/19/29) unchanged; `brief.ts` is the only new source file. **Recounted + live at land**, never pinned/guessed. + +## 6. Success criteria (testable) + +- **SC1 (rank/group)** — `rankForBrief(fixtureStore, pillars, today)` puts only `overlap ≥ 2 & fresh` in + `topMatches`, `overlap === 1 & fresh` in `singleMatches`, `overlap ≥ 1 & NOT fresh` in `olderMatched`; + excludes `overlap === 0`; within each group orders `overlap desc → effectiveDate desc → title asc`; + `matchedPillars` lists the actual matched names (case-insensitive match, original-case pillar preserved); + `totals.matched` = sum of the three groups, `totals.fresh` = top+single, `totals.trends` = `store.trends.length`. +- **SC2 (freshness)** — `effectiveDate = publishedAt ?? capturedAt`; an item whose `publishedAt` is within + `freshDays` but whose `capturedAt` is older is **fresh** (and the reverse via the fallback when `publishedAt` + is absent); the boundary `ageDays === freshDays` is **fresh** (`≤`); `freshDays` is configurable (a non-7 + value re-buckets). +- **SC3 (render/frontmatter)** — `renderBrief(ranking)` output begins with a YAML frontmatter block carrying + `date`, a **column-0, single-line `summary` with no embedded `"` and no newline** (so `extractYaml` reads it + whole), `store: { trends, matched, fresh }`, and `schemaVersion: 1`; `renderBrief`'s frontmatter `summary` + equals `briefSummary(ranking)` byte-for-byte (one source); the body has the three sections in order with the + pinned entry-line shape (§3); the **empty-matches** ranking renders a valid brief whose `summary` is the + "no fresh on-pillar signals" line (still surfaceable); identical input → identical bytes (determinism, total + order). `briefSummary(emptyRanking)` returns the no-fresh line; `briefSummary(freshRanking)` names the top + entry. +- **SC4 (CLI brief)** — `… brief --pillars a,b --store <tmp> --out <tmpdir>` writes `<tmpdir>/<today>.md` + (today-shaped `/^\d{4}-\d{2}-\d{2}$/`) and prints the path; `--json` emits `{ path, date, totals, summary }`; + `--fresh-days xyz` → exit **2**; **empty/absent `--pillars`** → writes a no-match brief, exit **0**; `--out` + overrides the dir; the default dir (no `--out`) is `defaultBriefDir()`. +- **SC5 (surfacing)** — running `session-start.mjs` (subprocess) with `LINKEDIN_STUDIO_DATA` pointing at a + fixture root containing `trends/morning-brief/<date>.md` → `additionalContext` contains `## Morning Brief + (<date>)`, the brief's `summary`, and `→ Full brief: <file>`, and carries **no raw newline** inside that + block (single-escaped `\n` idiom held); an absent brief dir → **no** block and **no crash** (`continue: + true`); the surfacing works with **no `tsx`/`node_modules` present** (zero-dep proof). **Path cross-check:** a + file written by the CLI at `defaultBriefDir()` (under a given `LINKEDIN_STUDIO_DATA`) is the one the hook + finds via `join(getDataRoot('trends'),'morning-brief')` — the CLI-write/hook-read paths resolve equal (the + store-path twin equivalence already guarded by `__tests__/data-root.test.mjs`). +- **SC6 (gate + wiring + de-niche) — TWO separate gate commands:** + **(a)** `bash scripts/test-runner.sh` → `FAIL=0`: trends suite green at the bumped `TRENDS_TESTS_FLOOR`; new + **Section 16i** green (`command === "brief"` in `cli.ts`, `src/cli.ts brief` in `trend-spotter.md`, + `latestMorningBrief` in `session-start.mjs`, non-vacuity self-test); `ASSERT_BASELINE_FLOOR` = live recount; + Section 17 de-niche green; counts 27/19/29. + **(b)** `node --test hooks/scripts/__tests__/` → the new `session-start-morning-brief.test.mjs` green (the + hook suite is **not** part of `test-runner.sh`). + *(If Open Q#1 → minimal: SC6(a) drops the `trend-spotter.md`/16i-wire clause; brief.ts + cli + surfacing + + de-niche + counts still asserted.)* + +## 7. Verification + +**Deterministic (two gates):** (a) `bash scripts/test-runner.sh` → `FAIL=0`; trends suite ≥ new floor; new +Section 16i self-test + greps pass; Section 17 de-niche green; ref/agent/command counts unchanged. (b) +`node --test hooks/scripts/__tests__/` → the new `session-start-morning-brief.test.mjs` green (hook suite is a +separate command, not part of `test-runner.sh`). +**Behavioural (manual):** +1. `echo '[{"source":"tavily","title":"A","url":"https://e/a","topics":["ai","gov"],"publishedAt":"<~2d + ago>"},{"source":"tavily","title":"B","url":"https://e/b","topics":["ai"],"publishedAt":"<~20d ago>"}]' | + node --import tsx src/cli.ts capture --store /tmp/r2b.json` (seed the store). +2. `node --import tsx src/cli.ts brief --pillars ai,gov --store /tmp/r2b.json --out /tmp/r2b-brief --json` → + confirm A in `topMatches` (overlap 2, fresh), B in `olderMatched` (overlap 1, stale), the written path, and a + `summary` naming A. +3. Place that brief at `<root>/trends/morning-brief/<today>.md` and run `LINKEDIN_STUDIO_DATA=<root> node + hooks/scripts/session-start.mjs` → confirm the `## Morning Brief` block + the `summary` appear in + `additionalContext`, with `tsx` absent. + +## 8. Open questions for the go-gate + +1. **Wire `brief` generation into `trend-spotter.md` (after capture) + Section 16i, or keep R2b to + machinery+surfacing only?** **Recommend WIRE** (mirrors R2a Open Q#1): a scan then *produces* the brief, so + the surfacing isn't surfacing an artifact nothing creates — it closes the scan→capture→brief→surfaced loop. + Minimal alt: `brief.ts` + CLI + surfacing + tests, no agent edit (the operator runs `brief` by hand). +2. **Brief dir = `trends/morning-brief/`** (colocated with the store it reads) — recommend. **Load-bearing** + (not a cheap toggle): it is baked into `defaultBriefDir()`, the hook twin, and SC5's path assertions. + Alternatives `research/morning-brief/` (the `docs/research-engine` naming the mock showed) or `morning-brief/` + at the data-root (substrate §3 B3 literal) would re-touch `brief.ts` + the hook + SC5. Confirm the path; a + different choice means updating those three places. +3. **Surfacing heading: English `## Morning Brief`** (matches the existing 14 hook section headings — + "Posting Reminders", "Queue Summary", "Brain") with a Norwegian `summary` body — recommend. Alt: the mock's + Norwegian `## 🌅 Morgen-brief`. (The block is `additionalContext` for the model, not direct user UI; the + user-facing *artifact* body stays Norwegian either way.) The one genuinely-cosmetic question here. +4. **`summary:` frontmatter field on the artifact** — **confirm** (this is a decision baked in, not a free + choice): the entire zero-tsx surfacing (SC3/SC5, `extractYaml(content,'summary')`) depends on it; without a + `summary` frontmatter line the hook would have to parse the body (which the no-tsx invariant forbids), so the + slice cannot ship without it. The approved visible *body* is unchanged; this only adds one frontmatter line + the hook reads. + +## 9. Light-Voyage review — folded + +Three Opus reviewers ran on the drafts, each verifying claims against live code. **scope-guardian: ALIGNED** +(every SC1–SC6 traces to a step; zero creep; all nine §4 non-goals held; counts 27/19/29 verified live; 0 +findings). **brief-reviewer: PROCEED_WITH_RISKS** (1 real risk + minors). **plan-critic: REVISE** (2 blockers, +5 majors, 4 minors). All findings folded; see `plan-re-r2b.md` §Plan-critic — folded for per-step resolution: + +- **[BLOCKER, folded]** `--json` summary source was an unresolved either/or ("re-derive OR expose"). → a + committed **`briefSummary(ranking)`** export is now the single source for both `renderBrief`'s frontmatter and + the CLI `--json` (§3; SC3 pins equality). Step 1 asserts against the named export. +- **[BLOCKER, folded]** Section 16i placement was ambiguous. → pinned: immediately after Section 16h (the last + section before 18; file order 17→16g→16h→18), before the Section 18 block (`:1118`). +- **[MAJOR, folded]** the throwing-stub RED claim contradicted "fail on assertion, not module-not-found". → + Step 1 now creates **wrong-but-non-throwing** stubs (empty buckets / `""`) so `brief.test.ts` fails on + *assertion*; `cli.test.ts` brief cases are logic-RED against the existing dispatch (unknown command → exit 2). +- **[MAJOR, folded]** brief §3 `outDir = flags.out ?? defaultBriefDir()` would write `./true/` for a bare + `--out`. → corrected to the `flags.out !== "true"` guard (`parseFlags` `cli.ts:52-53`); a bare-`--out` + cli.test case added. +- **[MAJOR, folded]** `TRENDS_TESTS_FLOOR` recount was ambiguous (full `tests N` vs additive). → pinned: the + `tests N` line after Steps 1–3; the inline `:697` comment appends `+ RE-R2b: brief +N + cli +M`. +- **[MAJOR, folded]** `defaultBriefDir` re-resolved the data root independently (triple-twin drift). → it now + **derives from `defaultStorePath()`** (`join(dirname(defaultStorePath()), "morning-brief")`) — one root + resolution; the hook-vs-CLI path equivalence is the store-path twin already guarded by `data-root.test.mjs`; + SC5 adds a write-then-read path cross-check. +- **[MAJOR, folded]** the surfacing newline-escape was unproven. → §3 + SC5 state `extractYaml`'s `[^"\n]*` + + `.trim()` makes `date`/`summary` newline-free → no `.replace` needed; SC5 asserts no raw newline in the block. +- **[brief-reviewer MAJOR, folded]** SC6 attributed "hook-suite at recount" to `bash scripts/test-runner.sh`, + which neither runs nor counts the hook suite (no `HOOK_TESTS_FLOOR`, no root `package.json`). → SC6 + §7 now + 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, folded]** non-total sort → added `url asc` final tie-break (true total order, §3/SC1). **[MINOR, + folded]** local `ageDays` math stated as a deliberate non-import (dependency direction `cli.ts → brief.ts`). + **[MINOR, folded]** `.md$`-anchored filename filter confirmed. **[MINOR, folded]** header-enumeration is a + prose chain → insert the 16i clause between the 16h and Section-18 clauses (`:43-46`). **[brief-reviewer + MINOR, folded]** body entry-line age format pinned in §3 + SC3. **[brief-reviewer MINOR, folded]** Open Q#2/#4 + reframed as load-bearing confirmations (§8). diff --git a/docs/research-engine/plan-re-r2b.md b/docs/research-engine/plan-re-r2b.md new file mode 100644 index 0000000..948d673 --- /dev/null +++ b/docs/research-engine/plan-re-r2b.md @@ -0,0 +1,282 @@ +# 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` | **EDIT** — `brief` 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` | **NEW** — `rankForBrief` grouping/freshness/sort + `renderBrief` frontmatter/`summary`/empty-case/determinism + `defaultBriefDir` | SC1, SC2, SC3 | +| `scripts/trends/tests/cli.test.ts` | **EDIT** — `brief` 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` | **EDIT** — `TRENDS_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.isNaN` → `usage("--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_DATA` → `additionalContext` 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 1–3 — 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.sh` → `FAIL=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 1–3, 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 SC1–SC6 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.