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

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

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

282 lines
24 KiB
Markdown
Raw Permalink 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.

# 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 13 — recounted live, NOT an additive guess. Stays **inside** the `if [ -x …/tsx ]`
deps guard. **Append** `+ RE-R2b: brief +N + cli +M (morning-brief)` to the inline per-slice breakdown comment
on `:697` so the comment can't drift from the number.
- Add **Section 16i** ("Trends Brief Wiring", RE-R2b), mirroring Section 16h's shape (`:1074-1116`). **Placement
(verified):** file order is 17→16g→16h→18 (`:943/:1010/:1074/:1118`), so **16h is the last section before
Section 18** — insert 16i **after 16h's closing `fi`/`echo ""` (~`:1116`), before the Section 18 block
(`:1118`)** (anti-erosion Section 18 must stay last so it counts every prior check). Four **unconditional**,
deps-absent-safe checks (pure `grep -qF`/self-test, no `tsx`):
(1) a non-vacuity self-test (probe carrying `src/cli.ts brief` accepted, one without rejected);
(2) `grep -qF 'command === "brief"' scripts/trends/src/cli.ts`;
(3) `grep -qF 'src/cli.ts brief' agents/trend-spotter.md`;
(4) `grep -qF 'latestMorningBrief' hooks/scripts/session-start.mjs` (surfacing is wired, not just doc'd).
- Bump `ASSERT_BASELINE_FLOOR` (`:1136`, currently 90) → **live recount** (= 90 + the new unconditional 16i
emitters; expected ~94, **recounted at land**). Update the **header-enumeration prose chain** (`:33-46`) by
inserting the 16i clause **between** the 16h clause (`:43-45`) and the Section-18 clause (`:46`), preserving
sentence flow (it's prose, not an append).
- **NOT touched here:** the hook suite has no floor in `test-runner.sh` (no `HOOK_TESTS_FLOOR`, no root
`package.json`); the new hook test is gated by `node --test hooks/scripts/__tests__/` (Step 4), separately.
## Step 7 — behavioural verification
`(cd scripts/trends && npm install)` if needed, then run brief §7's three behavioural steps (seed via
`capture`, generate via `brief --json`, surface via `session-start.mjs` with `LINKEDIN_STUDIO_DATA`). Confirm
the top-match grouping, the `summary`, and the surfaced block (tsx absent for the surfacing). Run full
`bash scripts/test-runner.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 13, comment appends `+ RE-R2b: brief +N + cli +M`.
- **[MAJOR] `defaultBriefDir` triple-twin root re-resolution** (drift risk). ✅ it now derives from
`defaultStorePath()` (one root resolution, Step 2); R6 + SC5(c) cite the existing `data-root.test.mjs` twin
guard and add a write-then-read cross-check.
- **[MAJOR] surfacing newline-escape unproven.** ✅ Step 4 + SC5 state `extractYaml`'s `[^"\n]*` + `.trim()`
makes `date`/`summary` newline-free → no `.replace` needed; SC5 asserts no raw `\n` in the block.
- **[brief-reviewer MAJOR] SC6 "hook-suite at recount" attributed to `test-runner.sh`** (which neither runs nor
counts it — no `HOOK_TESTS_FLOOR`, no root `package.json`). ✅ SC6 + the verification table split into two
gate commands: `test-runner.sh` (trends/16i/ASSERT/de-niche/counts) and `node --test hooks/scripts/__tests__/`
(the new hook test).
- **[MINOR] non-total sort order** (same title, different url ties fully). ✅ added `url asc` final tie-break
(Step 2/SC1); Step 1 adds a same-title/diff-url fixture. **[MINOR] local `ageDays` duplication unstated** ✅
Step 2 states the deliberate non-import (dependency direction `cli.ts → brief.ts`). **[MINOR] `.md$` anchor**
✅ confirmed in the filter regex (Step 4). **[MINOR] header-enumeration prose flow** ✅ Step 6 inserts the 16i
clause in-prose between the 16h and Section-18 clauses. **[brief-reviewer MINOR] body entry-line age format**
✅ pinned in brief §3 + SC3. **[brief-reviewer MINOR] Open Q#2/#4 are load-bearing** ✅ reframed as
confirmations (brief §8).
- **[plan-critic headless-readiness] N/A** — R2b executes **in-session, operator-driven** (driftsmodell), not
as a headless autonomous run, so per-step revert/halt clauses aren't needed (R1/R2a had none either).
**scope-guardian — ALIGNED:** every SC1SC6 traces to a step; zero creep; all nine §4 non-goals held (no
scheduler, no scoring change, no store-schema change, no relevance/saturation/status fields, no pillar-from-state
resolution, no delivery channel, no brief-diffing, no new agent/command); counts 27/19/29 verified live;
`brief.ts` the only new source file.