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
309 lines
27 KiB
Markdown
309 lines
27 KiB
Markdown
# 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 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).
|