# Brief — RE-R3a: persist the relevance score + rank the morning brief on it

> **Slice:** RE-R3a (research-engine rung-2, R3 slice 1 — research-*deepening*). R3 ("deepen the
> research engine") is an **arc** of 5 open hulls (substrate §1: autonomous trigger · freshness-as-seen-log ·
> relevance/saturation/status scoring · brief history+diff · A1–A4 fan-out). R3a takes the first: the
> **relevance** half of hull 5 (and the remainder of hull 3 — "the store schema lacks fields a brief ranks
> on"). It persists the composite relevance score the `trend-spotter` agent ALREADY computes, onto the store
> record, and makes `rankForBrief` order on it.
> **Predecessor:** RE-R1 (`score.ts`, B2 triage-scorer — built, tested, deterministic) + RE-R2a (`capture`
> bridge + `publishedAt`, schema v1→v2) + RE-R2b (`brief.ts` dated artifact + surfacing). R2b explicitly
> deferred this in its §4: *"the B2 triage scorer stays out of the brief path — its output isn't persisted on
> records yet — that's R3."* R3a is exactly that R3 step.
> **Substrate:** `docs/research-engine-concepts.local.md` §1 hull (3) (schema fields a brief ranks on:
> relevance/...) + (5) (relevance scoring) + §B2 ("scoring/filtering as a gate before expensive work — the
> output is the rank key") + §A2 ("curate/score before synthesis — the writer sees ranked material").
> **TDD-order:** RED tests land before code — but as **two phases** (light-Voyage BLOCKER fold): the
> store/brief/cli tests are true logic-RED against the pre-edit code (they build fixtures inline, import no new
> symbol); the score/item tests reference not-yet-existing `score.ts` exports, so under Node16 ESM a missing
> named import throws at module-load (not on assertion) — they are RED against **non-throwing stubs** landed as
> the first GREEN-prep sub-step. See plan Step 1.

## 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."* RE-R2 made the stream **visible** (a dated morning brief surfaced
at session-start). R2b ranks that brief on **pillar-overlap + recency only** — a coarse proxy for "good topic
to write about." The actual relevance judgment (audience pull, timing, angle potential, authority, depth) lives
in the five 1–10 dimension scores the `trend-spotter` agent produces in Step 2 and pipes to the `score` CLI —
and is then **thrown away** before the trend reaches the store (Step 4.5 builds a *separate*, score-free capture
batch). R3a stops discarding it: persist the composite + band on the record, and rank the brief on composite
first. **The slice the operator chose** ("scoring inn i briefen", 2026-06-24) — the highest-leverage next step
on the core need (better-ordered suggestions), built on already-shipped-but-dormant code (`score.ts` is tested
and unused on records). The bigger R3 arcs (autonomous trigger / seen-log / saturation+status / A1–A4 fan-out)
stay later slices.

**Go-gate resolutions — CONFIRMED (operator "Go", 2026-06-24; baked into the plan):** **D1** persist the
**4-field** `TrendScore { mode, dimensions, composite, priority }` (composite+priority to rank/display, mode to
disambiguate the instrument, dimensions for audit + lossless re-weight). **D2** composite is the **primary
within-bucket sort** (buckets still assigned by overlap+freshness; composite orders *inside* a bucket). **D3**
score is **first-sight** (set on add, never updated on re-capture — matches the store's provenance discipline;
re-score-on-recapture pairs with the R3b seen-log/status slice). **D4** ship persist+rank as **one** slice (the
operator named the visible payoff; splitting would land an invisible schema-only cut like R2a).

## 2. The gap — grounded in code

- **The score the agent computes never reaches the store.** `trend-spotter.md` scores each candidate's five
  dimensions and pipes them to the `score` CLI (`agents/trend-spotter.md:134-140`), which returns
  `{composite, band}` per candidate (`score.ts:110-122` `triage`). But Step 4.5's capture batch
  (`agents/trend-spotter.md:291-298`) is **built separately and carries no score** — `source/title/url/topics/
  publishedAt/summary` only. `TrendItem` (`item.ts:22-39`) and `TrendRecord` (`types.ts:26-48`) have **no score
  field**. The relevance judgment is recomputed for the digest and discarded for persistence.
- **`score.ts` is built, tested, deterministic — and unconsumed on records.** It exports `composite()`
  (`score.ts:77-88`) and `band()` (`score.ts:91-97`) as pure functions, pinned to the SSOT
  (`references/trend-scoring-modes.md`, by `score.test.ts:12-30` weights + the band-string assertions). Nothing
  persists their output. `TrendRecord`'s own doc-comment anticipates the field: *"can gain fields (relevance
  score, first-mover timing, status) in a later slice"* (`types.ts:21-23`).
- **The brief ranks on a proxy.** `rankForBrief` sorts each bucket `overlap desc → effectiveDate desc →
  title asc → url asc` (`brief.ts:94-104`). Overlap (a hard pillar count) is *part* of what the composite
  already weights (Pillar Fit 30 %, `trend-scoring-modes.md:43`), but the composite also captures audience/
  timing/angle/authority — signal the brief currently can't see. `brief.ts`'s own header already names this as
  the next slice: *"A persisted relevance/saturation score … (R3)"* (`brief.ts:11-14`).

## 3. Scope — what is IN (RE-R3a)

### S-score — `scripts/trends/src/score.ts` (EDIT)
- **`export interface TrendScore { mode: ScoreMode; dimensions: DimensionScores; composite: number; priority:
  Priority }`** — the persist-ready envelope. Lives in `score.ts` (the score domain owns it); `types.ts` imports
  it (one-way: `score.ts` imports nothing — verified leaf, `:1-17` — so no cycle).
- **`export function requiredDimensions(mode: ScoreMode): string[]`** — `Object.keys(WEIGHTS[mode])`
  (`score.ts:37-40`). **Contract: ordered** — the keys come back in the SSOT weight-literal order (kortform
  `["pillar","audience","timing","angle","authority"]`, long-form `["pillar","depth","angle","authority",
  "currency"]`, `score.ts:20-35`); SC1 deep-equals that ordered array, and `score.test` pins the order so a
  silent SSOT reorder fails loudly. `normalizeItem` consumes it as a **set** (membership), which is order-safe
  either way. `WEIGHTS` stays private; the keys are exposed via this function.
- **`export function scoreEnvelope(mode: ScoreMode, dimensions: DimensionScores): TrendScore`** — composes the
  existing pure functions: `const c = composite(dimensions, mode); return { mode, dimensions, composite: c,
  priority: band(c).priority }`. **No new arithmetic** — `composite()`+`band()` stay the single owners (SSOT
  discipline). It throws (via `composite`, `score.ts:83`) on an out-of-range dimension — that is its
  **contract**, exercised directly by SC1/SC2; on the capture path it is unreachable because `normalizeItem`
  pre-validates (below).

### S-types — `scripts/trends/src/types.ts` (EDIT)
- `import type { TrendScore } from "./score.js";`
- `TrendRecord` gains **`score?: TrendScore;`** (optional — pre-R3a records simply lack it; the `add` manual
  path and unscored adopters omit it). Doc-comment updated to mark `score` as the now-realized field the
  `:21-23` note anticipated.
- **`SCHEMA_VERSION = 2 → 3`** (`types.ts:62`). The bump is the only schema signal; the record shape change is
  additive-optional, so the migration is the version-stamp alone (below).

### S-store — `scripts/trends/src/store.ts` (EDIT)
- `TrendInput` (`store.ts:26-35`) gains **`score?: TrendScore;`** (imported from `score.js`).
- `addTrend` (`store.ts:120-140`): on a **new** record, persist `score` first-sight via the existing
  conditional-spread idiom (`...(input.score !== undefined ? { score: input.score } : {})`, mirroring
  `publishedAt`/`summary` `:134,136`). On a **duplicate**, score is **NOT** updated (D3 — first-sight, like
  `source`/`capturedAt`/first `publishedAt`); topics still union (`:124-126`, unchanged). `AddResult` is
  unchanged (no new flag).
- `loadStore` migrate comment (`:79-84`): extend to *"v1→v2→v3 are all purely additive-optional (an old record
  is already a valid record that simply lacks the optional field), so the migration is the version stamp alone —
  records pass through untouched."* **No code change** to the migration logic (`Math.max(onDisk, SCHEMA_VERSION)`
  `:87` already does v2→v3 correctly; `saveStore` `JSON.stringify` `:95` preserves the `score` field — no field
  stripping); only `SCHEMA_VERSION` (in `types.ts`) and the comment move.

### S-item — `scripts/trends/src/item.ts` (EDIT)
- `TrendItem` (`item.ts:22-39`) gains **`score?: { mode: ScoreMode; dimensions: DimensionScores };`** — the
  ingress envelope carries the agent's *judgment* (five scores + mode), **not** a precomputed composite (the
  store computes it, so the composite has one owner). `import type { ScoreMode, DimensionScores } from
  "./score.js"` + `import { requiredDimensions } from "./score.js"`.
- `normalizeItem` (`:86-119`): if `r.score` present, **validate structurally** (returns a structured error into
  `errors[]`, never throws — the existing discipline, like the `publishedAt` ISO check `:99-106`): `score` is a
  **non-array** object; `mode ∈ {kortform, long-form}`; `dimensions` is a **non-array** object; **each key in
  `requiredDimensions(mode)` is present and a number in [1,10]**. On any failure → `errors.push("invalid score:
  …")`. On success carry the **validated** `score = { mode, dimensions }` forward (the validated dimensions
  object, not raw `r.score.dimensions`). Absent/null/invalid → key omitted. This guarantees the *capture path*
  (`cli.ts:246-254`: `normalizeItems` → `itemToInput`) never reaches `composite` with bad dims.
- `itemToInput` (`:129-139`): if `item.score` present → add `score: scoreEnvelope(item.score.mode,
  item.score.dimensions)` to the returned `TrendInput` (conditional spread, key omitted when absent). The
  item→store bridge is the natural place to turn judgment into the persisted envelope. `itemToInput` is a public
  function: called directly (e.g. in a test) with unvalidated dims it **throws by contract** (defense-in-depth);
  the no-throw guarantee is a property of the *capture path*, not of `itemToInput` in isolation (§5).

### S-brief — `scripts/trends/src/brief.ts` (EDIT)
- `rankForBrief` (`:72-114`): **composite becomes the primary within-bucket sort key** (D2). The comparator
  (`:94-98`) gains a leading term:
  `(b.trend.score?.composite ?? -1) - (a.trend.score?.composite ?? -1) || <existing overlap desc → effectiveDate
  desc → title asc → url asc>`. **Sentinel `-1`, not `-Infinity`** — composite is a weighted sum of [1,10]
  dims so it is always ≥ 1.0 (min = 1×Σweights = 1.0, verified); `-1` sorts every unscored record below every
  scored one and subtracts cleanly (`-Infinity - -Infinity = NaN` would corrupt the comparator). **Buckets are
  UNCHANGED** — assignment stays `overlap≥2 & fresh` / `overlap==1 & fresh` / `!fresh` (`:100-104`); composite
  only re-orders *within* a bucket. Total order preserved: the `(title,url)` pair is unique per store (it is the
  dedupe id, `store.ts:66-68`), so the final `url asc` tie-break makes the order insertion-independent even for
  equal composites.
- `renderBrief` (`:152-191`): surface the band **and mode** where a record is scored (so a reader can tell a
  kortform "High" from a long-form "High" — the two are different instruments). **Pinned line shapes:**
  - Top-entry meta line (`renderTopEntry`, `:135`), scored:
    `- Kilde: <source> · Publisert: <date> (<age>d) · <priority> (<mode>) · Pillarer: <matched>`
    (the `· <priority> (<mode>)` token sits between `(<age>d)` and `· Pillarer`); **unscored: unchanged** (no
    token).
  - Bullet line (`renderBulletEntry`, `:144`), scored:
    `- **<title>** — «<matched>» · <date> (<age>d) · <priority> (<mode>) · 🔗 <url>`
    (token **before** `· 🔗`); **unscored: unchanged**.
  - The `ranking:` frontmatter descriptor (`:160`) → the **exact** string
    `composite desc, then pillar-overlap desc, then publishedAt desc (capturedAt fallback); freshDays <N>`
    (pinned verbatim; `brief.test` asserts it byte-for-byte).
- `briefSummary` (`:122-130`): the top mention names the **band only** (mode stays a body-entry detail to keep
  the one-line headline clean) — fresh>0 with a **scored** top → `… Topp: «<title>» (<pillar> · <priority> ·
  <age>d).`; fresh>0 with an **unscored** top → `… Topp: «<title>» (<pillar> · <age>d).` (no token). **Still one
  line, no `"`, no `\n`** — the `extractYaml` contract (`brief.ts:118-120`) holds; the band strings
  (`Immediate`/`High`/…) are bare words.
- `BRIEF_SCHEMA_VERSION` stays **1** (no frontmatter *field* added/removed; the surfacing hook still reads
  `date`+`summary`; only the `ranking:` descriptor *string* and body content change). Bumping is an Open Q (§8),
  not required for correctness.

### S-cli — `scripts/trends/src/cli.ts` (EDIT, doc-only behavior)
- The `capture` branch (`:243-269`) folds via `itemToInput` (`:254`) — so once `item.ts` threads `score`,
  capture **automatically** persists it with **no logic change**. Update only the header doc-comment
  (`:15-21`) to note capture now persists an optional relevance score. The `score` CLI (`:218-241`, the digest
  path) and the `add` manual path (`:123-147`, score-free) are unchanged. *(Capture's `{added, merged,
  duplicates, errors}` tally is left unchanged — a `scored` count is an Open-Q nice-to-have, §8.)*

### Wiring (D-default — WIRE, mirrors R2a/R2b Open Q#1)
- `agents/trend-spotter.md` (EDIT): Step 4.5's capture batch (`:291-298`) gains a per-item **`"score": {"mode":
  "kortform", "dimensions": {"pillar": N, "audience": N, "timing": N, "angle": N, "authority": N}}`** — the same
  five judgment scores the agent computed in Step 2 (`:134`), carried into capture so the store persists them and
  the brief ranks on them. Prose explains the carry ("don't discard the Step-2 scores — fold them into the
  capture batch"). Mode defaults `kortform`; `long-form` when invoked from `/linkedin:newsletter` (long-form
  dims `pillar/depth/angle/authority/currency`). Domain-general (dimensions are the rubric's, pillars are the
  user's config; no vendor/sector tokens). Keep the "skip silently if no deps" escape hatch. **Verified
  non-vacuous:** `agents/trend-spotter.md` does NOT currently contain the literal `"dimensions"`, so the
  Section 16j grep passes only after the wire is added.
- `scripts/trends/README.md` (EDIT): document the item `score` field (judgment in), the persisted `TrendScore`
  (composite/priority out), and that the brief now ranks on composite.
- `scripts/test-runner.sh` (EDIT): bump `TRENDS_TESTS_FLOOR` (`:701`, currently 104) to the `tests N` line
  reported after Steps 1–6, **append** `+ RE-R3a: score +N` to the inline breakdown comment (`:701`). Add
  **Section 16j** ("Trends Score Wiring", RE-R3a) **after Section 16i's closing `echo ""` (~`:1171`), before the
  Section 18 block (`:1173`)** (16i is the last 16x before anti-erosion; file order 17→16g→16h→16i→18,
  `:947/:1014/:1078/:1122/:1173`). Mirror 16i's shape: **unconditional**, deps-absent-safe `grep -qF` + a
  non-vacuity self-test emitting **one** pass/fail (so the count is exact) — (1) self-test; (2) `export
  interface TrendScore` in `score.ts`; (3) `score?: TrendScore` in `types.ts`; (4) `"dimensions"` in
  `agents/trend-spotter.md`; (5) `score?.composite` in `brief.ts`. **5 unconditional emitters → bump
  `ASSERT_BASELINE_FLOOR` 94 → exactly 99** (`:1193`; "live recount" is the safety net, but the expected value
  is the pinned 94 + 5 = 99). Update the header-enumeration **prose chain** by inserting the 16j clause between
  the 16i clause (`:46-49`) and the Section-18 clause (`:49`), preserving sentence flow.

## 4. Non-goals — what is OUT (deferred)

- **Re-score on re-capture** (refresh the score when a trend is re-seen) — **R3b**. R3a is first-sight only
  (D3). Re-score pairs naturally with the seen-log/status slice (the Timing dimension decays, so a refresh is a
  real improvement — but it expands `addTrend`'s mutation surface and wants the status/lifecycle model alongside).
- **Mode-segmented / mode-normalized ranking** — OUT. R3a ranks **all** records by composite regardless of mode;
  a kortform composite and a long-form composite are different instruments (different dimensions,
  `trend-scoring-modes.md:50,68`), so the ranking is **mode-blind by design for R3a**. This is acceptable because
  (a) almost all records are `kortform` (the default), and (b) the body entry line **shows the mode** (`<priority>
  (<mode>)`) so the operator can see when two adjacent entries were scored on different instruments. A
  mode-segmented brief (separate sections per mode) or a `--mode` filter is a later refinement.
- **Saturation / status (acted/skipped) / first-mover-as-a-field** (the rest of hull 5) — **R3b+**. R3a does the
  **relevance** half of hull 5 only.
- **Autonomous nightly trigger** (cron/launchd, hull 1) — **R3 later**. No scheduler enters the repo.
- **Freshness as a persisted seen-log / dedup-vs-seen (B4)** — **R3 later**.
- **Brief history surfacing / diff ("yesterday vs today", hull 7)** — **R3 later**.
- **Research-deepening A1–A4** (plan → isolated parallel workers → gap loop → curate), adapter sub-agents, MCP
  fetch fan-out — **R3 later** (the big slice).
- **A new `score` field in the `add` manual CLI path** — OUT. `add` stays the raw, score-free manual path; only
  the normalizing `capture` path carries scores.
- **`BRIEF_SCHEMA_VERSION` bump** — OUT by default (no frontmatter field changes); Open Q#5.
- **New source file / new agent / new command** — none. R3a is all edits to the six existing `src/*.ts` +
  one agent + README + gate. Counts stay 27/19/29.

## 5. Boundaries / invariants (must hold)

- **TDD iron law (two-phase RED):** the failing tests land **BEFORE** the implementation. `store`/`brief`/`cli`
  tests are true logic-RED against the pre-edit code (inline fixtures, no new import). `score`/`item` tests
  reference new `score.ts` exports → under Node16 ESM a missing named import throws at module-load, so they are
  RED against **non-throwing stubs** landed first (the stubs return wrong-but-present values; the value
  assertions then fail). The plan records the RED proof in two phases (Step 1); it does NOT claim a single
  "all five fail on assertion before any code" run.
- **One composite owner:** `composite()` + `band()` (`score.ts`) stay the sole arithmetic; `scoreEnvelope`
  *composes* them, never re-derives. The agent supplies judgment, the code computes the composite (SSOT
  discipline, `score.test.ts:12-30` pins the weights/bands).
- **Purity:** `scoreEnvelope`/`requiredDimensions`/`rankForBrief`/`renderBrief` touch no fs, no clock, no env,
  no AI. All fs stays at the CLI edge.
- **No throw on the capture path (not "everywhere"):** `normalizeItem` fully validates the score before
  `itemToInput`, so the capture loop (`cli.ts:246-258`) never reaches `composite` with bad dims and never
  crashes (a bad score → `errors[]`). `itemToInput`/`scoreEnvelope`/`composite` called **directly** with bad
  dims throw by contract — that is the defense-in-depth boundary, asserted (SC2), not a leak.
- **Determinism:** same `(store, pillars, today, freshDays)` → byte-identical brief (the composite sort is a
  total order via the unique `(title,url)` final tie-break; `-1` sentinel for unscored is deterministic).
- **Lossless additive migration (both directions):** a v2 store loads as v3 with records **untouched** (no
  `score` invented); round-trip writes `schemaVersion: 3`; a v3 store is idempotent; a v3 store's new optional
  `score` field **survives a load+resave** (no field stripping, `JSON.stringify` `store.ts:95`). Mirrors the R2a
  v1→v2 proof (`store.test.ts:403-476`) + a new field-preservation case.
- **Hook unaffected:** the SessionStart surfacing reads `date`+`summary` only and **never shells out to tsx**
  (analytics fresh-clone-crash invariant) — R3a touches neither the hook nor the frontmatter schema, so the
  zero-tsx surfacing is unchanged. (No hook test added; the existing hook suite must still pass untouched as a
  regression sanity.)
- **Domain-general:** Section 17 de-niche stays green; the `trend-spotter.md` edit carries the rubric's
  dimension names + the user's pillars, **no vendor/sector tokens**.
- **No SSOT change:** `references/trend-scoring-modes.md` (weights/bands/actions) untouched; `score.ts` mirrors
  it exactly as today.
- **No store-query change:** `queryByTopic`/`history`/`newestCaptureDate` untouched; the brief recomputes
  overlap as before (`queryByTopic` NOT refactored).
- **Pathguard:** all edits are to **existing** files (no new `.mjs` under `hooks/scripts/`; no new `.ts` —
  R3a adds *no* source file). `.gitignore` already covers `scripts/trends/{node_modules,build}`.
- **Counts** (refs/agents/commands 27/19/29) unchanged. **Recounted live at land**, never pinned/guessed.

## 6. Success criteria (testable)

- **SC1 (score envelope)** — `requiredDimensions("kortform")` **deep-equals (ordered)** `["pillar","audience",
  "timing","angle","authority"]`; `requiredDimensions("long-form")` deep-equals `["pillar","depth","angle",
  "authority","currency"]` (the `WEIGHTS` literal order, `score.ts:20-35`), and `score.test` pins the order so a
  SSOT reorder fails. `scoreEnvelope("kortform", {pillar:8,audience:7,timing:9,angle:6,authority:5})` returns
  `{ mode:"kortform", dimensions:<the five>, composite: composite(dims,"kortform"), priority: band(composite).
  priority }` — composite/priority equal the existing functions' output byte-for-byte (one owner); a bad
  dimension makes `scoreEnvelope` throw (via `composite`).
- **SC2 (item validation + bridge + the throw contract)** — `normalizeItem` on an item with a valid `score`
  carries the **validated** dims; with a bad `mode`, a missing dimension, a dimension out of [1,10], a non-object
  `score`, or an **array** `dimensions` → `{ ok:false, errors:["invalid score: …"] }` (structured, **never
  throws**); absent `score` → key omitted. `itemToInput(validItemWithScore, capturedAt)` returns a `TrendInput`
  whose `score` is `scoreEnvelope(mode, dimensions)` (composite/priority computed); without a score → no `score`
  key; **`itemToInput` called directly with an out-of-range dim throws** (the defense-in-depth contract).
- **SC3 (first-sight persist)** — `addTrend(store, inputWithScore)` on a **new** title+url persists `score` on
  the record; re-`addTrend` of the same title+url with a **different** score does **NOT** change the stored
  score (first-sight, D3) while topics still union; an input **without** a score adds a score-free record.
- **SC4 (migration v2→v3, both directions)** — a `schemaVersion:2` store with records lacking `score` loads as
  **v3**, records intact, **no `score` invented**; round-trip `loadStore→saveStore` writes `schemaVersion:3`; a
  v3 store with `score` on records loads idempotent; **a v3 store's `score` field survives load+resave** (field
  preservation of a new optional field — not covered by the mirrored v1→v2 block). Mirrors
  `store.test.ts:403-476`, **retitled `(RE-R3a / score v2→v3)` with every `schemaVersion` assertion literal
  flipped `2`→`3`.**
- **SC5 (brief ranks on composite)** — within a bucket, `rankForBrief` orders **composite desc** first
  (a composite-9 record ahead of a composite-6 record at the **same overlap**); an **unscored** record sorts
  **after** every scored record in its bucket (the `-1` sentinel) and then by the existing keys; buckets are
  unchanged (still overlap+freshness); the order is a **total order** (same-title/diff-url, both unscored →
  fixed by `url asc`); same input → byte-identical brief (determinism).
- **SC6 (render surfaces band + mode)** — `renderBrief` emits the **full pinned line shapes** (§3): a scored
  top-entry shows `· <priority> (<mode>)` between `(<age>d)` and `· Pillarer`; a scored bullet shows `·
  <priority> (<mode>)` before `· 🔗`; an **unscored** entry renders the **unchanged** line (no token) — both
  asserted as **full lines, not substrings**. `briefSummary` names the band (no mode) on a scored top, omits the
  token on an unscored top, and stays one line with no `"`/`\n` **even when the top title contains a guillemet/
  quote** (the only new code path touching the summary). The `ranking:` frontmatter descriptor equals the pinned
  string verbatim. A store whose only fresh match is a **single-pillar unscored** record → `briefSummary` renders
  with no `· <priority>` token, one line.
- **SC7 (CLI persists score end-to-end)** — `echo '[{…,"score":{"mode":"kortform","dimensions":{…valid…}}}]'
  | … capture --store <tmp>` then `… list --store <tmp> --json` shows the record carrying a `score` with the
  computed composite/priority; a batch with one **bad** score → that item in `errors[]`, the valid ones added,
  **exit 0** (the run isn't failed).
- **SC8 (gate + wiring + de-niche)** — `bash scripts/test-runner.sh` → `FAIL=0`: trends suite green at the
  bumped `TRENDS_TESTS_FLOOR`; new **Section 16j** green (`TrendScore` in `score.ts`, `score?: TrendScore` in
  `types.ts`, `"dimensions"` in `trend-spotter.md`, `score?.composite` in `brief.ts`, non-vacuity self-test);
  `ASSERT_BASELINE_FLOOR` = **99** (94 + 5); Section 17 de-niche green; counts 27/19/29.

## 7. Verification

**Deterministic:** `bash scripts/test-runner.sh` → `FAIL=0`; trends suite ≥ new floor; new Section 16j
self-test + greps pass; `ASSERT_BASELINE_FLOOR` = 99; Section 17 de-niche green; ref/agent/command counts
unchanged. **Regression sanity:** `node --test hooks/scripts/__tests__/` → still green untouched (R3a touches no
hook; adds no hook test).
**Behavioural (manual):**
1. `echo '[{"source":"tavily","title":"A","url":"https://e/a","topics":["ai","gov"],"publishedAt":"<~2d ago>",
   "score":{"mode":"kortform","dimensions":{"pillar":9,"audience":8,"timing":9,"angle":7,"authority":6}}},
   {"source":"tavily","title":"B","url":"https://e/b","topics":["ai","gov"],"publishedAt":"<~2d ago>",
   "score":{"mode":"kortform","dimensions":{"pillar":6,"audience":5,"timing":6,"angle":5,"authority":5}}}]'
   | node --import tsx src/cli.ts capture --store /tmp/r3a.json` — both overlap-2 & fresh, A scored higher.
2. `node --import tsx src/cli.ts list --store /tmp/r3a.json --json` → confirm both records carry `score`
   with computed composite/priority.
3. `node --import tsx src/cli.ts brief --pillars ai,gov --store /tmp/r3a.json --out /tmp/r3a-brief --json` →
   confirm **A precedes B** in `topMatches` (higher composite, same overlap+freshness), the entry line shows
   `· <priority> (kortform)`, and the `summary` names A with its band.
4. Append a bad-score item (`"timing":99`) to the batch and re-`capture` → confirm it lands in `errors[]`,
   the valid items still added, exit 0.

## 8. Open questions for the go-gate — RESOLVED

D1–D4 confirmed by the operator ("Go", 2026-06-24): **D1** 4-field `TrendScore`; **D2** composite primary within
bucket; **D3** first-sight; **D4** one slice (data-then-visible commit order within it). Two residual cosmetics,
both baked to the recommended default:
- **D5 — `BRIEF_SCHEMA_VERSION` 1→2?** No (no frontmatter field added/removed; the hook reads only
  `date`+`summary`). Re-open only if the artifact should self-announce the ranking change.
- **D6 — mode in the per-entry render?** YES (folded from plan-critic #3): the body entry shows `<priority>
  (<mode>)`; the summary shows the band only. This makes the mode-blind ranking honest (the reader can see the
  instrument).

## 9. Light-Voyage review — folded

Three Opus reviewers ran on the drafts, each verifying claims against live code. **scope-guardian: ALIGNED**
(every SC1–SC8 traces to a step; zero creep; all §4 non-goals held; counts 27/19/29 verified live; "no new
source file" verified — exactly 6 `src/*.ts` + 5 `tests/*.test.ts`, all edited, none added; 0 findings).
**brief-reviewer: PROCEED_WITH_RISKS** (all four load-bearing claims — score.ts-is-a-leaf/no-cycle, composite ≥
1.0, version-stamp-only migration, single-owner arithmetic — verified TRUE; 6 MINOR). **plan-critic: REVISE**
(1 BLOCKER, 4 MAJOR, 4 MINOR). All findings folded; see `plan-re-r3a.md §Plan-critic — folded` for per-step
resolution. Headlines:

- **[BLOCKER, folded]** the "all five test files fail on assertion after Step 1" RED claim is false for
  `score`/`item` under Node16 ESM (a missing named import throws at module-load, not on assertion). → RED is now
  **explicitly two-phase**: logic-RED for `store`/`brief`/`cli` against pre-edit code; stub-first then
  assertion-RED for `score`/`item` (§5; plan Step 1; the header blockquote).
- **[MAJOR, folded]** the no-throw guarantee was overstated ("unreachable" — but `itemToInput` is public and
  throws on direct bad-dim calls). → reworded **path-specific** (no throw on the capture path; direct calls throw
  by contract); SC2 asserts both (§5, §6).
- **[MAJOR, folded]** mode-mixing was waved away and "mode shown per entry" contradicted the render spec (which
  only showed priority). → the render now shows `<priority> (<mode>)` per body entry (D6); §4 states mode-blind
  ranking is accepted for R3a with the mode visible; SC6 asserts the full line incl. mode.
- **[MAJOR, folded]** `requiredDimensions` order contract was ambiguous (SC1 hard-coded arrays vs membership
  use). → pinned **ordered** (SC1 deep-equals the SSOT-order array; `score.test` pins order; `normalizeItem` uses
  membership) (§3 S-score, SC1).
- **[MAJOR, folded]** `ASSERT_BASELINE_FLOOR` "~99" was not pinned. → pinned **99** (94 + 5 unconditional 16j
  emitters; self-test emits one pass/fail like 16i) (§3 wiring, SC8).
- **[MINOR, folded]** SC4 ref `:403-471` stale + pointed at v2 assertions → `:403-476` + "flip every
  `schemaVersion` literal 2→3" note (SC4). **[MINOR, folded]** R1 SSOT-pin cite was the doc-comment → now
  `score.test.ts:12-30` (§2, §5; plan R1). **[MINOR, folded]** bullet `· <priority>` placement was substring-only
  → full pinned line shape, priority+mode before `🔗`, asserted as a full line (§3, SC6). **[MINOR, folded]**
  three diverging `ranking:` descriptor strings → one verbatim target, asserted byte-for-byte (§3, SC6). **[MINOR,
  folded]** unscored single-match-top summary path untested → added as an SC6 case. **[MINOR, folded]**
  `normalizeItem` non-array object case understated → "non-array" added to both object checks + SC2. **[MINOR,
  folded]** header-chain line-ref tightened to the 16i clause `:46-49` / Section-18 `:49`. **[MINOR, folded]** R9
  DAG now lists the three new one-way `score.ts ←` edges. **[MINOR, folded]** SC4 forward-compat /
  score-survives-round-trip added. **[MINOR, folded]** SC6 quote-safety regression (scored top title with a
  guillemet) added.
