# 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 `/.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 `; 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 --out ` writes `/.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 ? ` ferske tema-signaler matcher pillarene dine. Topp: «» ( · d).` : `Ingen ferske tema-signaler på pillarene dine (av 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()` = `/trends/trends.json`, so `dirname` = `/trends`, + `morning-brief` = `/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: (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/.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 `** 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 `/.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.