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

27 KiB
Raw Permalink Blame History

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)

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(): stringderives 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).

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>.mdadditionalContext 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.shFAIL=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.shFAIL=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 artifactconfirm (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).