# 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) || `. **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: · Publisert: (d) · () · Pillarer: ` (the `· ()` token sits between `(d)` and `· Pillarer`); **unscored: unchanged** (no token). - Bullet line (`renderBulletEntry`, `:144`), scored: `- **** — «<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.