linkedin-studio/docs/research-engine/brief-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

309 lines
27 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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: <BRIEF_SCHEMA_VERSION>`) + the body. **Body entry line (pinned):**
`### <n>. <title>` 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 13 (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 (A1A4), 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 SC1SC6 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 13; 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).