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
27 KiB
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 +publishedAtpersistence, landed7a15803) 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) persistedpublishedAtfirst-sight and shipped thecaptureCLI; 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,
TrendRecordcarriespublishedAt?(types.ts:43) and thecaptureCLI folds polled trends in (cli.ts:237-263), but no artifact ranks or presents the accumulated store. The read surfaces today arequery(topic-scoped,cli.ts:143-164) andlist(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. publishedAtis persisted but never ranked on.queryByTopicrankstopicOverlap 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'srankForBriefis 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 computesoverlap(count ofpillarspresent intrend.topics, case-insensitive, the same idiom asqueryByTopicstore.ts:151-152— computed inline,queryByTopicis NOT refactored),matchedPillars(the actual matched names),effectiveDate = publishedAt ?? capturedAt, andageDays = Math.floor((Date.parse(today) - Date.parse(effectiveDate)) / 86400000)(a local day-delta, not imported fromcli.ts'sdaysBetweencli.ts:107-109— that would invert the dependency direction, sincecli.tsimportsbrief.ts, not the reverse). Groups:topMatches(overlap ≥ 2ANDageDays ≤ freshDays),singleMatches(overlap === 1AND fresh),olderMatched(overlap ≥ 1AND NOT fresh).overlap === 0is excluded entirely (off-pillar noise). Within each group: sortoverlap desc, theneffectiveDate desc(freshest first), thentitle asc, thenurl asc— a total order (two records can share title+effectiveDate+overlap but never title+url, since title+url is the dedupe idstore.ts:66-68; theurlkey makes order independent of store insertion / V8 sort stability). Returns{ today, freshDays, totals: { trends, matched, fresh }, topMatches, singleMatches, olderMatched }.freshDaysdefault 7 (D3).renderBrief(ranking: BriefRanking): string— produces the full Markdown artifact: a YAML frontmatter block (date, a one-linesummarythe hook surfaces verbatim,store: { trends, matched, fresh }, aranking: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 frontmattersummaryis produced by the sharedbriefSummary(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 byrenderBrief(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'sextractYamlregex (^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 fromdefaultStorePath()(store.ts:190-193):join(dirname( defaultStorePath()), "morning-brief")→<root>/trends/morning-brief. One root resolution (reusesdefaultStorePath, imported fromstore.js); no independent re-resolution ofLINKEDIN_STUDIO_DATAinbrief.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'sSCHEMA_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.5capture(re-pointed in R2a), add a step that runsnode --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 literalsrc/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 thebriefsubcommand + thetrends/morning-brief/YYYY-MM-DD.mdartifact + its frontmatter shape (honest CLI/artifact doc).scripts/test-runner.sh(EDIT): bumpTRENDS_TESTS_FLOOR— set it to thetests Nline reported by(cd scripts/trends && npm test)after Steps 1–3 (it stays inside the deps guard; 79 today,store.tscomment:697is 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 closingfi/echo ""(~:1116), before the Section 18 block (:1118). Mirror 16h's shape: unconditional, deps-absent-safegrep -qF+ a non-vacuity self-test — (1) self-test; (2)command === "brief"incli.ts; (3)src/cli.ts briefinagents/trend-spotter.md; (4)latestMorningBriefinsession-start.mjs(surfacing is wired, not merely documented). These are unconditional → bumpASSERT_BASELINE_FLOOR90 → 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): newhooks/scripts/__tests__/session-start-morning-brief.test.mjs(mirrorssession-start-trends-staleness.test.mjs: subprocess +LINKEDIN_STUDIO_DATAfixture), green undernode --test hooks/scripts/__tests__/(the command that runs the 136-test hook suite).test-runner.shhas noHOOK_TESTS_FLOORand no rootpackage.json— so SC6 asserts the hook test under its own command, never as part ofbash 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 ≤ freshDaysat 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).
- recency only**; the B2 triage scorer (
- 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.tsrecord shape andSCHEMA_VERSION(2) are untouched (only a puredefaultBriefDirpath helper is added, inbrief.ts). - No new agent/command — counts stay 19/29/27.
briefis a CLI subcommand; surfacing is a hook edit; generation is wired into the existingtrend-spotter.
5. Boundaries / invariants (must hold)
- TDD iron law: the failing
brief.tstests (rankForBriefgrouping/freshness/sort +renderBrieffrontmatter/summary/empty-case +defaultBriefDir) and thecli.ts brieftests 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/defaultBriefDirtouch no fs, no clock, no env-at-call, no AI —today/pillars/freshDaysare 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 totsx(analytics fresh-clone-crash invariant). A fixture run with nonode_modules/tsxinscripts/trendsmust still surface the brief (SC5). - Domain-general: Section 17 de-niche guard stays green; the
trend-spotter.mdedit + 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.tsrecord shape +SCHEMA_VERSIONunchanged;queryByTopicNOT refactored (overlap is recomputed inbrief.ts). - Pathguard:
brief.tsis a NEW.ts(allowed); NO new.mjsunderhooks/scripts/(surfacing edits the existingsession-start.mjs);.gitignorealready coversscripts/trends/{node_modules,build}. - House conventions: ESM +
node:test+tsx; brief+plan docs live underdocs/(uncounted, TRACKED likedocs/second-brain/*). - Counts (refs/agents/commands 27/19/29) unchanged;
brief.tsis the only new source file. Recounted live at land, never pinned/guessed.
6. Success criteria (testable)
- SC1 (rank/group) —
rankForBrief(fixtureStore, pillars, today)puts onlyoverlap ≥ 2 & freshintopMatches,overlap === 1 & freshinsingleMatches,overlap ≥ 1 & NOT freshinolderMatched; excludesoverlap === 0; within each group ordersoverlap desc → effectiveDate desc → title asc;matchedPillarslists 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 whosepublishedAtis withinfreshDaysbut whosecapturedAtis older is fresh (and the reverse via the fallback whenpublishedAtis absent); the boundaryageDays === freshDaysis fresh (≤);freshDaysis configurable (a non-7 value re-buckets). - SC3 (render/frontmatter) —
renderBrief(ranking)output begins with a YAML frontmatter block carryingdate, a column-0, single-linesummarywith no embedded"and no newline (soextractYamlreads it whole),store: { trends, matched, fresh }, andschemaVersion: 1;renderBrief's frontmattersummaryequalsbriefSummary(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 whosesummaryis 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;--jsonemits{ path, date, totals, summary };--fresh-days xyz→ exit 2; empty/absent--pillars→ writes a no-match brief, exit 0;--outoverrides the dir; the default dir (no--out) isdefaultBriefDir(). - SC5 (surfacing) — running
session-start.mjs(subprocess) withLINKEDIN_STUDIO_DATApointing at a fixture root containingtrends/morning-brief/<date>.md→additionalContextcontains## Morning Brief (<date>), the brief'ssummary, and→ Full brief: <file>, and carries no raw newline inside that block (single-escaped\nidiom held); an absent brief dir → no block and no crash (continue: true); the surfacing works with notsx/node_modulespresent (zero-dep proof). Path cross-check: a file written by the CLI atdefaultBriefDir()(under a givenLINKEDIN_STUDIO_DATA) is the one the hook finds viajoin(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 bumpedTRENDS_TESTS_FLOOR; new Section 16i green (command === "brief"incli.ts,src/cli.ts briefintrend-spotter.md,latestMorningBriefinsession-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 newsession-start-morning-brief.test.mjsgreen (the hook suite is not part oftest-runner.sh). (If Open Q#1 → minimal: SC6(a) drops thetrend-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):
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).node --import tsx src/cli.ts brief --pillars ai,gov --store /tmp/r2b.json --out /tmp/r2b-brief --json→ confirm A intopMatches(overlap 2, fresh), B inolderMatched(overlap 1, stale), the written path, and asummarynaming A.- Place that brief at
<root>/trends/morning-brief/<today>.mdand runLINKEDIN_STUDIO_DATA=<root> node hooks/scripts/session-start.mjs→ confirm the## Morning Briefblock + thesummaryappear inadditionalContext, withtsxabsent.
8. Open questions for the go-gate
- Wire
briefgeneration intotrend-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 runsbriefby hand). - Brief dir =
trends/morning-brief/(colocated with the store it reads) — recommend. Load-bearing (not a cheap toggle): it is baked intodefaultBriefDir(), the hook twin, and SC5's path assertions. Alternativesresearch/morning-brief/(thedocs/research-enginenaming the mock showed) ormorning-brief/at the data-root (substrate §3 B3 literal) would re-touchbrief.ts+ the hook + SC5. Confirm the path; a different choice means updating those three places. - Surfacing heading: English
## Morning Brief(matches the existing 14 hook section headings — "Posting Reminders", "Queue Summary", "Brain") with a Norwegiansummarybody — recommend. Alt: the mock's Norwegian## 🌅 Morgen-brief. (The block isadditionalContextfor the model, not direct user UI; the user-facing artifact body stays Norwegian either way.) The one genuinely-cosmetic question here. 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 asummaryfrontmatter 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]
--jsonsummary source was an unresolved either/or ("re-derive OR expose"). → a committedbriefSummary(ranking)export is now the single source for bothrenderBrief'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 /
"") sobrief.test.tsfails on assertion;cli.test.tsbrief 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 theflags.out !== "true"guard (parseFlagscli.ts:52-53); a bare---outcli.test case added. - [MAJOR, folded]
TRENDS_TESTS_FLOORrecount was ambiguous (fulltests Nvs additive). → pinned: thetests Nline after Steps 1–3; the inline:697comment appends+ RE-R2b: brief +N + cli +M. - [MAJOR, folded]
defaultBriefDirre-resolved the data root independently (triple-twin drift). → it now derives fromdefaultStorePath()(join(dirname(defaultStorePath()), "morning-brief")) — one root resolution; the hook-vs-CLI path equivalence is the store-path twin already guarded bydata-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()makesdate/summarynewline-free → no.replaceneeded; 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 (noHOOK_TESTS_FLOOR, no rootpackage.json). → SC6 + §7 now split into two gate commands:test-runner.sh(trends/16i/ASSERT/de-niche/counts) andnode --test hooks/scripts/__tests__/(the new hook test). - [MINOR, folded] non-total sort → added
url ascfinal tie-break (true total order, §3/SC1). [MINOR, folded] localageDaysmath stated as a deliberate non-import (dependency directioncli.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).